From 73ea44562d6d0ebcf01c03932d199ca8c6e417d1 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Sat, 27 Jun 2026 23:54:00 +0000 Subject: [PATCH 01/12] 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 606868fe7..8a5d50f44 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; @@ -27,6 +28,8 @@ pub mod service; pub mod trust; 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 b34e41386..8ff6ae55d 100644 --- a/crates/keystone/src/api/v3/domain/mod.rs +++ b/crates/keystone/src/api/v3/domain/mod.rs @@ -28,7 +28,8 @@ mod update; #[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 e8107f014..323a80709 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; @@ -34,6 +35,10 @@ pub(super) fn openapi_router() -> OpenApiRouter { .routes(routes!(password::change_password)) .routes(routes!(groups::groups)) .merge(os_ec2::openapi_router()) + .nest( + "/{user_id}/application_credentials", + application_credential::openapi_router(), + ) } #[cfg(test)] From cc42d7c334e9e750ce8e7c7d98b46bd3b5daf76d Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Sun, 28 Jun 2026 23:46:57 +0000 Subject: [PATCH 02/12] 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 1ce72dda9..2179a2f50 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -622,6 +622,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 02214e34b8c7ed9dbda32a8c4a36de2bc0af1e42 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Tue, 7 Jul 2026 22:37:25 +0000 Subject: [PATCH 03/12] 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 | 2 + .../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, 1152 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 2179a2f50..5fb15f7a4 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -253,19 +253,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")) } err @ ApplicationCredentialProviderError::AccessRulesUnenforced => { Self::BadRequest(err.to_string()) @@ -622,40 +636,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 419a4968d..7963799cc 100644 --- a/crates/appcred-driver-sql/src/lib.rs +++ b/crates/appcred-driver-sql/src/lib.rs @@ -112,6 +112,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 d3c4b3bc5..6d10f8863 100644 --- a/crates/core/src/application_credential/service.rs +++ b/crates/core/src/application_credential/service.rs @@ -290,6 +290,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 d405f037f..67048f80e 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -437,6 +437,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 1b33e6da7..69dfc3149 100644 --- a/tests/api/src/identity.rs +++ b/tests/api/src/identity.rs @@ -11,5 +11,6 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +pub mod application_credential; pub mod group; 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 3b0e69e09..63a57dce9 100644 --- a/tests/api/tests/api_v3/identity.rs +++ b/tests/api/tests/api_v3/identity.rs @@ -11,5 +11,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + +mod application_credential; mod group; 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 3e250abb8d2678bae9deae9a3a3f65cb203e3656 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Tue, 7 Jul 2026 23:07:14 +0000 Subject: [PATCH 04/12] 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] From ac279eec6e4a9437c2471a1c52f2ead689962afa Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Wed, 15 Jul 2026 22:49:34 +0000 Subject: [PATCH 05/12] fix: Fix the issues Signed-off-by: Hamza Konac --- .../v3/application_credential/access_rule.rs | 38 ++++++- .../application_credential.rs | 59 +++++++++- .../src/v3/application_credential_conv.rs | 103 ++++++++++-------- .../src/application_credential/delete.rs | 11 +- .../application_credential/provider_api.rs | 4 +- .../src/application_credential/service.rs | 58 +++++----- crates/core/src/mocks.rs | 2 +- .../v3/user/application_credential/create.rs | 57 ++++------ .../v3/user/application_credential/delete.rs | 55 ++++------ .../v3/user/application_credential/list.rs | 50 ++++----- .../v3/user/application_credential/show.rs | 51 ++++----- .../user/application_credential/create.rego | 6 +- .../application_credential/create_test.rego | 27 ++++- .../user/application_credential/delete.rego | 7 +- .../application_credential/delete_test.rego | 13 ++- policy/user/application_credential/list.rego | 8 +- .../application_credential/list_test.rego | 15 ++- policy/user/application_credential/show.rego | 8 +- .../application_credential/show_test.rego | 15 ++- .../src/identity/application_credential.rs | 26 ++++- .../identity/application_credential/create.rs | 2 +- .../identity/application_credential/list.rs | 17 --- .../identity/application_credential/show.rs | 2 +- 23 files changed, 380 insertions(+), 254 deletions(-) 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 8bbd389ab..f7837ee09 100644 --- a/crates/api-types/src/v3/application_credential/access_rule.rs +++ b/crates/api-types/src/v3/application_credential/access_rule.rs @@ -14,6 +14,11 @@ use serde::{Deserialize, Serialize}; /// Short access rule representation. +/// +/// Access rules are fine-grained permissions attached to application +/// credentials. Each rule constrains the credential to a specific service +/// type, HTTP method, and API path. Once created, an access rule can be +/// viewed and deleted independently of the application credential. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[cfg_attr( feature = "builder", @@ -26,23 +31,30 @@ use serde::{Deserialize, Serialize}; #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct AccessRule { - /// The ID of the access rule. + /// Unique identifier of the access rule. This ID can be used to reuse an + /// existing access rule when creating a new application credential. #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] pub id: String, - /// The HTTP method permitted. + /// HTTP method that this access rule permits (e.g., `GET`, `POST`, `PUT`, + /// `DELETE`, `PATCH`). #[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. + /// API path pattern that this access rule permits. Supports wildcard + /// syntax: `*` matches a single path segment, `**` matches any number + /// of segments recursively, and `{variable}` matches a named path + /// parameter. For example, `/v2.1/servers/*/ips` or `/v2.1/**`. #[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. + /// OpenStack service type that this access rule applies to + /// (e.g., `compute`, `monitoring`, `identity`). Matched against the + /// service catalog. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] @@ -50,6 +62,11 @@ pub struct AccessRule { } /// Access rule for creation (id is optional). +/// +/// When creating an application credential, access rules can either be +/// defined inline (with `method`, `path`, and `service`) or reference an +/// existing rule by its `id`. All fields are optional so that both creation +/// patterns are supported. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[cfg_attr( feature = "builder", @@ -62,21 +79,34 @@ pub struct AccessRule { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct AccessRuleCreate { + /// Optional identifier of an existing access rule to reuse. When + /// provided, the other fields (`method`, `path`, `service`) are ignored + /// and the referenced rule is attached to the new application credential. #[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, + /// HTTP method that this access rule permits (e.g., `GET`, `POST`, `PUT`, + /// `DELETE`, `PATCH`). Required when creating a new rule inline (i.e., + /// without specifying `id`). #[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, + /// API path pattern that this access rule permits. Supports wildcard + /// syntax: `*` matches a single path segment, `**` matches any number + /// of segments recursively, and `{variable}` matches a named path + /// parameter. Required when creating a new rule inline. #[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, + /// OpenStack service type that this access rule applies to + /// (e.g., `compute`, `monitoring`, `identity`). Matched against the + /// service catalog. Required when creating a new rule inline. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] 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 ec683ecd3..9310078a4 100644 --- a/crates/api-types/src/v3/application_credential/application_credential.rs +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -34,36 +34,50 @@ use crate::v3::role::RoleRef; #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredential { + /// Optional list of access rules that restrict which API requests this + /// credential is permitted to make. Each rule specifies a service, HTTP + /// method, and URL path. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "validate", validate(nested))] pub access_rules: Option>, + /// Optional human-readable description of the application credential's + /// purpose. #[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, + /// Optional expiration date and time for the application credential. After + /// this timestamp the credential is no longer valid. When `None`, the + /// credential does not expire. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, + /// Unique identifier of the application credential. #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] pub id: String, + /// User-provided name of the application credential. Must be unique within + /// the owning user's set of application credentials. #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] pub name: String, + /// Identifier of the project that the application credential is scoped to. #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] pub project_id: String, + /// List of roles delegated to this application credential. These must be a + /// subset of the roles the owning user holds on the target project. #[cfg_attr(feature = "validate", validate(nested))] pub roles: Vec, + /// Whether this application credential has unrestricted access. When + /// `false` (the default), the credential cannot be used to create + /// additional application credentials or trusts. pub unrestricted: bool, - - #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] - pub user_id: String, } /// Data for creating an application credential. @@ -79,30 +93,45 @@ pub struct ApplicationCredential { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialCreate { + /// Optional list of access rules to restrict which API requests the new + /// credential is allowed to make. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "validate", validate(nested))] pub access_rules: Option>, + /// Optional human-readable description of the application credential. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Optional expiration date and time. When `None`, the credential does not + /// expire. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, + /// Optional client-supplied identifier for the application credential. If + /// not provided, the server generates one. #[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, + /// Name of the application credential. Must be unique among the owning + /// user's application credentials. #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] pub name: String, + /// Roles to delegate to the new credential. Must be a subset of the + /// user's roles on the project. Defaults to all of the user's roles on the + /// project when empty. #[cfg_attr(feature = "builder", builder(default))] pub roles: Vec, + /// Whether to allow unrestricted access. When `true`, the credential can + /// create additional application credentials or trusts, which is + /// potentially dangerous. Defaults to `false`. #[cfg_attr(feature = "builder", builder(default))] #[serde(skip_serializing_if = "Option::is_none")] pub unrestricted: Option, @@ -113,6 +142,7 @@ pub struct ApplicationCredentialCreate { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialResponse { + /// The application credential object. #[cfg_attr(feature = "validate", validate(nested))] pub application_credential: ApplicationCredential, } @@ -122,6 +152,7 @@ pub struct ApplicationCredentialResponse { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialCreateRequest { + /// The application credential creation payload. #[cfg_attr(feature = "validate", validate(nested))] pub application_credential: ApplicationCredentialCreate, } @@ -131,25 +162,38 @@ pub struct ApplicationCredentialCreateRequest { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialCreated { + /// Optional list of access rules associated with the credential. #[serde(skip_serializing_if = "Option::is_none")] pub access_rules: Option>, + /// Optional human-readable description of the application credential. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Optional expiration date and time. `None` means the credential does not + /// expire. #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, + /// Unique identifier of the newly created application credential. pub id: String, + + /// Name of the application credential. pub name: String, + + /// Identifier of the project the credential is scoped to. pub project_id: String, + + /// List of roles delegated to this application credential. pub roles: Vec, - /// Only present in create response. Never returned again. + /// The secret used for authentication. The secret is hashed before storage, + /// so this is the only time it is returned in plaintext. If lost, a new + /// application credential must be created. pub secret: String, + /// Whether this credential has unrestricted access. pub unrestricted: bool, - pub user_id: String, } /// Wrapper for create response body. @@ -157,6 +201,8 @@ pub struct ApplicationCredentialCreated { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialCreateResponse { + /// The newly created application credential, including the one-time + /// secret. pub application_credential: ApplicationCredentialCreated, } @@ -165,6 +211,7 @@ pub struct ApplicationCredentialCreateResponse { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialList { + /// Collection of application credentials belonging to the user. #[cfg_attr(feature = "validate", validate(nested))] pub application_credentials: Vec, } @@ -174,6 +221,8 @@ pub struct ApplicationCredentialList { #[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialListParameters { + /// Optional name filter. When set, only application credentials whose + /// name matches this value are returned. #[cfg_attr(feature = "validate", validate(length(max = 255)))] pub name: Option, } diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs index 4399c4b47..5c2e09929 100644 --- a/crates/api-types/src/v3/application_credential_conv.rs +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -29,18 +29,6 @@ impl From for api_types_access_rule::AccessRule { } } -impl From for provider_types::AccessRuleCreate { - fn from(value: api_types_access_rule::AccessRuleCreate) -> Self { - Self { - id: value.id, - method: value.method, - path: value.path, - service: value.service, - user_id: String::new(), // assigned server-side - } - } -} - impl From for api_types_application_credential::ApplicationCredential { @@ -56,15 +44,14 @@ impl From project_id: value.project_id, roles: value.roles.into_iter().map(Into::into).collect(), unrestricted: value.unrestricted, - user_id: value.user_id, } } } -impl From - for provider_types::ApplicationCredentialCreate +impl From + for api_types_application_credential::ApplicationCredentialCreated { - fn from(value: api_types_application_credential::ApplicationCredentialCreate) -> Self { + fn from(value: provider_types::ApplicationCredentialCreateResponse) -> Self { Self { access_rules: value .access_rules @@ -73,45 +60,75 @@ impl From expires_at: value.expires_at, id: value.id, name: value.name, - project_id: String::new(), // assigned server-side from token + project_id: value.project_id, roles: value.roles.into_iter().map(Into::into).collect(), - secret: None, // generated server-side + secret: value.secret.expose_secret().to_string(), unrestricted: value.unrestricted, - user_id: String::new(), // assigned server-side from token } } } -impl From - for provider_types::ApplicationCredentialListParameters +impl From + for provider_types::ApplicationCredentialCreateBuilder { - fn from(value: api_types_application_credential::ApplicationCredentialListParameters) -> Self { - Self { - limit: Default::default(), - marker: Default::default(), - name: value.name, - user_id: String::new(), // injected from auth context, not from request body + fn from(value: api_types_application_credential::ApplicationCredentialCreate) -> Self { + let mut builder = provider_types::ApplicationCredentialCreateBuilder::default(); + builder.name(value.name); + builder.roles(value.roles.into_iter().map(Into::into).collect::>()); + if let Some(v) = value.access_rules { + builder.access_rules( + v.into_iter() + .map(|r| { + provider_types::AccessRuleCreateBuilder::from(r) + .build() + .unwrap() + }) + .collect::>(), + ); + } + if let Some(v) = value.description { + builder.description(v); + } + if let Some(v) = value.expires_at { + builder.expires_at(v); + } + if let Some(v) = value.id { + builder.id(v); } + if let Some(v) = value.unrestricted { + builder.unrestricted(v); + } + builder } } -impl From - for api_types_application_credential::ApplicationCredentialCreated +impl From for provider_types::AccessRuleCreateBuilder { + fn from(value: api_types_access_rule::AccessRuleCreate) -> Self { + let mut builder = provider_types::AccessRuleCreateBuilder::default(); + if let Some(v) = value.id { + builder.id(v); + } + if let Some(v) = value.method { + builder.method(v); + } + if let Some(v) = value.path { + builder.path(v); + } + if let Some(v) = value.service { + builder.service(v); + } + builder + } +} + +impl From + for provider_types::ApplicationCredentialListParametersBuilder { - 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, + fn from(value: api_types_application_credential::ApplicationCredentialListParameters) -> Self { + let mut builder = provider_types::ApplicationCredentialListParametersBuilder::default(); + if let Some(v) = value.name { + builder.name(v); } + builder } } diff --git a/crates/appcred-driver-sql/src/application_credential/delete.rs b/crates/appcred-driver-sql/src/application_credential/delete.rs index 151fccab4..fb5367b95 100644 --- a/crates/appcred-driver-sql/src/application_credential/delete.rs +++ b/crates/appcred-driver-sql/src/application_credential/delete.rs @@ -28,20 +28,11 @@ pub async fn delete( db: &DatabaseConnection, id: &str, ) -> Result<(), ApplicationCredentialProviderError> { - let app_cred = DbApplicationCredential::find() + let res = DbApplicationCredential::delete_many() .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)] diff --git a/crates/core/src/application_credential/provider_api.rs b/crates/core/src/application_credential/provider_api.rs index d23731b7d..0372d792e 100644 --- a/crates/core/src/application_credential/provider_api.rs +++ b/crates/core/src/application_credential/provider_api.rs @@ -74,7 +74,7 @@ pub trait ApplicationCredentialApi: Send + Sync { /// /// # Parameters /// - `state`: The current service state. - /// - `id`: The ID of the application credential to delete. + /// - `rec`: The application credential deletion request. /// /// # Returns /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or @@ -82,7 +82,7 @@ pub trait ApplicationCredentialApi: Send + Sync { async fn delete_application_credential<'a>( &self, ctx: &ExecutionContext<'a>, - id: &'a str, + rec: ApplicationCredential, ) -> Result<(), ApplicationCredentialProviderError>; /// Get a user's access rule by its ID. diff --git a/crates/core/src/application_credential/service.rs b/crates/core/src/application_credential/service.rs index 6d10f8863..d451ddacd 100644 --- a/crates/core/src/application_credential/service.rs +++ b/crates/core/src/application_credential/service.rs @@ -294,7 +294,7 @@ impl ApplicationCredentialApi for ApplicationCredentialService { /// /// # Parameters /// - `state`: The current service state. - /// - `id`: The ID of the application credential to delete. + /// - `rec`: The application credential deletion request. /// /// # Returns /// - `Result<(), ApplicationCredentialProviderError>` - Unit on success, or @@ -302,33 +302,39 @@ impl ApplicationCredentialApi for ApplicationCredentialService { async fn delete_application_credential<'a>( &self, ctx: &ExecutionContext<'a>, - id: &'a str, + rec: ApplicationCredential, ) -> 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, + if let Some(vsc) = ctx.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &ctx.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Delete, + EventPayload::ApplicationCredential { id: rec.id.to_string(), project_id: rec.project_id.to_string() } , + ), + operation: async { + backend_driver.delete_application_credential(ctx.state(), &rec.id).await }, - )) - .await; + on_audit_error: |_: AuditDispatchError| { + ApplicationCredentialProviderError::Driver("audit dispatch failed".into()) + }, + }?; + } else { + self.backend_driver + .delete_application_credential(ctx.state(), &rec.id) + .await?; + ctx.state() + .event_dispatcher + .emit(Event::new( + Operation::Delete, + EventPayload::ApplicationCredential { + id: rec.id.to_string(), + project_id: rec.project_id.to_string(), + }, + )) + .await; + } Ok(()) } diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index 67048f80e..4373bb1e3 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -440,7 +440,7 @@ mod application_credential { async fn delete_application_credential<'a>( &self, ctx: &ExecutionContext<'a>, - id: &'a str, + rec: ApplicationCredential, ) -> Result<(), ApplicationCredentialProviderError>; async fn get_access_rule<'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 a0fe34415..893a05083 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -28,6 +28,7 @@ use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; use crate::keystone::ServiceState; use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::application_credential as core_type_application_credential; use openstack_keystone_core_types::auth::ScopeInfo; /// Create application credential. /// @@ -46,7 +47,6 @@ use openstack_keystone_core_types::auth::ScopeInfo; ), tag = "application_credentials" )] - pub(super) async fn create( Auth(user_auth): Auth, Path(user_id): Path, @@ -54,23 +54,7 @@ pub(super) async fn create( 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.", - ))); - } + let execution_context = ExecutionContext::from_auth(&state, &user_auth); // project_id must come from the token scope, not the request body let project_id = match user_auth.authorization().map(|a| &a.scope) { @@ -82,27 +66,39 @@ pub(super) async fn create( } }; + let mut target = serde_json::to_value(&payload.application_credential)?; + target["user_id"] = json!(user_id); + state .policy_enforcer .enforce( "identity/user/application_credential/create", &user_auth, - json!({"user_id": user_id}), + json!({"application_credential": target}), None, ) .await?; - let mut app_cred: openstack_keystone_core_types::application_credential::ApplicationCredentialCreate - = payload.application_credential.into(); + let app_cred = core_type_application_credential::ApplicationCredentialCreateBuilder::from( + payload.application_credential, + ) + .user_id(user_id.clone()) + .project_id(project_id) + .build() + .unwrap(); - // Inject server-side fields — never trust the request body for these - app_cred.user_id = user_id.clone(); - app_cred.project_id = project_id; + // Verify user exists — 404 if not found + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; let created = state .provider .get_application_credential_provider() - .create_application_credential(&ExecutionContext::from_auth(&state, &user_auth), app_cred) + .create_application_credential(&execution_context, app_cred) .await .map_err(KeystoneApiError::from)?; @@ -113,7 +109,6 @@ pub(super) async fn create( }), )) } - #[cfg(test)] mod tests { use axum::{ @@ -355,16 +350,8 @@ mod tests { #[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 state = get_mocked_state(Provider::mocked_builder(), false, None).await; let response = openapi_router() .layer(TraceLayer::new_for_http()) 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 64f140f34..efbfdfaf9 100644 --- a/crates/keystone/src/api/v3/user/application_credential/delete.rs +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -40,46 +40,42 @@ 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))?; + let execution_context = ExecutionContext::from_auth(&state, &user_auth); - // Fetch credential to get real user_id for policy enforcement - // Mirrors Python _update_request_user_id_attribute() security fix + // Fetch credential first — needed for policy and audit let current = state .provider .get_application_credential_provider() - .get_application_credential( - &ExecutionContext::from_auth(&state, &user_auth), - &application_credential_id, - ) + .get_application_credential(&execution_context, &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 + // Policy check — uses credential's real user_id, not URL parameter state .policy_enforcer .enforce( "identity/user/application_credential/delete", &user_auth, - json!({"user_id": current.user_id}), + json!({"application_credential": serde_json::to_value(¤t)?}), None, ) .await?; + // Verify user exists + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + state .provider .get_application_credential_provider() - .delete_application_credential( - &ExecutionContext::from_auth(&state, &user_auth), - &application_credential_id, - ) + .delete_application_credential(&execution_context, current) .await .map_err(KeystoneApiError::from)?; @@ -174,9 +170,16 @@ mod tests { let mut identity_mock = MockIdentityProvider::default(); identity_mock.expect_get_user().returning(|_, _| Ok(None)); + 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), + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_application_credential(app_cred_mock), true, None, ) @@ -203,9 +206,6 @@ mod tests { #[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() @@ -213,9 +213,7 @@ mod tests { let vsc = test_fixture_scoped(); let state = get_mocked_state( - Provider::mocked_builder() - .mock_identity(identity_mock) - .mock_application_credential(app_cred_mock), + Provider::mocked_builder().mock_application_credential(app_cred_mock), true, None, ) @@ -242,9 +240,6 @@ mod tests { #[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() @@ -252,9 +247,7 @@ mod tests { let vsc = test_fixture_scoped(); let state = get_mocked_state( - Provider::mocked_builder() - .mock_identity(identity_mock) - .mock_application_credential(app_cred_mock), + Provider::mocked_builder().mock_application_credential(app_cred_mock), false, None, ) 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 7fdb13736..36ea84876 100644 --- a/crates/keystone/src/api/v3/user/application_credential/list.rs +++ b/crates/keystone/src/api/v3/user/application_credential/list.rs @@ -28,6 +28,7 @@ use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; use crate::keystone::ServiceState; use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::application_credential as core_type_application_credential; #[utoipa::path( get, @@ -48,32 +49,38 @@ pub(super) async fn list( State(state): State, ) -> Result { payload.validate()?; + let execution_context = ExecutionContext::from_auth(&state, &user_auth); - // 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))?; - + // Policy check first + let mut target = serde_json::to_value(&payload)?; + target["user_id"] = json!(user_id); state .policy_enforcer .enforce( "identity/user/application_credential/list", &user_auth, - json!({"user_id": user_id}), + json!({"application_credential": target}), 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(); + // Verify user exists + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + + let filter = + core_type_application_credential::ApplicationCredentialListParametersBuilder::from(payload) + .user_id(user_id.clone()) + .build() + .unwrap(); let application_credentials = state .provider .get_application_credential_provider() - .list_application_credentials(&ExecutionContext::from_auth(&state, &user_auth), &filter) + .list_application_credentials(&execution_context, &filter) .await .map_err(KeystoneApiError::from)?; @@ -266,23 +273,8 @@ mod tests { #[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 state = get_mocked_state(Provider::mocked_builder(), false, None).await; let response = openapi_router() .layer(TraceLayer::new_for_http()) 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 5690e0bfd..665cf83cb 100644 --- a/crates/keystone/src/api/v3/user/application_credential/show.rs +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -43,37 +43,38 @@ pub(super) async fn show( 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 execution_context = ExecutionContext::from_auth(&state, &user_auth); + // Fetch credential first — needed for policy let current = state .provider .get_application_credential_provider() - .get_application_credential( - &ExecutionContext::from_auth(&state, &user_auth), - &application_credential_id, - ) + .get_application_credential(&execution_context, &application_credential_id) .await .map_err(KeystoneApiError::from)? .ok_or_else(|| { KeystoneApiError::not_found("application_credential", &application_credential_id) })?; + // Policy check — uses stored object's real user_id state .policy_enforcer .enforce( "identity/user/application_credential/show", &user_auth, - json!({"user_id": current.user_id}), + json!({"application_credential": serde_json::to_value(¤t)?}), None, ) .await?; + // Verify user exists + state + .provider + .get_identity_provider() + .get_user(&execution_context, &user_id) + .await? + .ok_or_else(|| KeystoneApiError::not_found("user", &user_id))?; + Ok(( StatusCode::OK, Json(ApplicationCredentialResponse { @@ -176,7 +177,6 @@ mod tests { ApiApplicationCredentialBuilder::default() .id("existing-id") .name("test-cred") - .user_id("uid") .project_id("pid") .unrestricted(false) .roles(vec![]) @@ -192,14 +192,20 @@ mod tests { let mut identity_mock = MockIdentityProvider::default(); identity_mock.expect_get_user().returning(|_, _| Ok(None)); + 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), + 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) @@ -220,9 +226,6 @@ mod tests { #[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() @@ -230,14 +233,11 @@ mod tests { let vsc = test_fixture_scoped(); let state = get_mocked_state( - Provider::mocked_builder() - .mock_identity(identity_mock) - .mock_application_credential(app_cred_mock), + Provider::mocked_builder().mock_application_credential(app_cred_mock), true, None, ) .await; - let response = openapi_router() .layer(TraceLayer::new_for_http()) .with_state(state) @@ -258,9 +258,6 @@ mod tests { #[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() @@ -268,9 +265,7 @@ mod tests { let vsc = test_fixture_scoped(); let state = get_mocked_state( - Provider::mocked_builder() - .mock_identity(identity_mock) - .mock_application_credential(app_cred_mock), + Provider::mocked_builder().mock_application_credential(app_cred_mock), false, None, ) diff --git a/policy/user/application_credential/create.rego b/policy/user/application_credential/create.rego index 085b58d8f..53d3f9dcf 100644 --- a/policy/user/application_credential/create.rego +++ b/policy/user/application_credential/create.rego @@ -5,5 +5,9 @@ package identity.user.application_credential.create default allow := false allow if { - input.credentials.user_id == input.target.user_id + input.credentials.user_id == input.target.application_credential.user_id +} + +violation contains {"field": "user_id", "msg": "creating application credentials for a different user is not allowed."} if { + input.credentials.user_id != input.target.application_credential.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 index 5525ed45f..a7a9e76cc 100644 --- a/policy/user/application_credential/create_test.rego +++ b/policy/user/application_credential/create_test.rego @@ -3,9 +3,32 @@ 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"}} + create.allow with input as { + "credentials": {"user_id": "uid"}, + "target": {"application_credential": {"user_id": "uid"}}, + } +} + +test_owner_no_violation if { + count(create.violation) == 0 with input as { + "credentials": {"user_id": "uid"}, + "target": {"application_credential": {"user_id": "uid"}}, + } } test_non_owner_forbidden if { - not create.allow with input as {"credentials": {"user_id": "other"}, "target": {"user_id": "uid"}} + not create.allow with input as { + "credentials": {"user_id": "other"}, + "target": {"application_credential": {"user_id": "uid"}}, + } +} + +test_non_owner_violation if { + violation := create.violation with input as { + "credentials": {"user_id": "other"}, + "target": {"application_credential": {"user_id": "uid"}}, + } + count(violation) == 1 + some v in violation + v.field == "user_id" } \ No newline at end of file diff --git a/policy/user/application_credential/delete.rego b/policy/user/application_credential/delete.rego index d244fc7a1..9f4d58b56 100644 --- a/policy/user/application_credential/delete.rego +++ b/policy/user/application_credential/delete.rego @@ -6,4 +6,9 @@ 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 +allow if { input.credentials.user_id == input.target.application_credential.user_id } + +violation contains {"field": "user_id", "msg": "deleting application credentials of a different user is not allowed."} if { + not input.credentials.is_admin + input.credentials.user_id != input.target.application_credential.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 index 71ffef6cf..a8fe45903 100644 --- a/policy/user/application_credential/delete_test.rego +++ b/policy/user/application_credential/delete_test.rego @@ -3,13 +3,20 @@ 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"}} + delete.allow with input as {"credentials": {"is_admin": true}, "target": {"application_credential": {"user_id": "uid"}}} } test_owner_allowed if { - delete.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} + delete.allow with input as {"credentials": {"user_id": "uid"}, "target": {"application_credential": {"user_id": "uid"}}} } test_non_owner_forbidden if { - not delete.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"user_id": "uid"}} + not delete.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_violation if { + v := delete.violation with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + count(v) == 1 + some e in v + e.field == "user_id" } \ No newline at end of file diff --git a/policy/user/application_credential/list.rego b/policy/user/application_credential/list.rego index 4bf712453..d8ef5b5c1 100644 --- a/policy/user/application_credential/list.rego +++ b/policy/user/application_credential/list.rego @@ -11,4 +11,10 @@ allow if { input.credentials.system == "all" } -allow if { input.credentials.user_id == input.target.user_id } \ No newline at end of file +allow if { input.credentials.user_id == input.target.application_credential.user_id } + +violation contains {"field": "user_id", "msg": "listing application credentials of a different user is not allowed."} if { + not input.credentials.is_admin + not input.credentials.system == "all" + input.credentials.user_id != input.target.application_credential.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 index 758dfc523..a0d19bd87 100644 --- a/policy/user/application_credential/list_test.rego +++ b/policy/user/application_credential/list_test.rego @@ -3,17 +3,24 @@ 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"}} + list.allow with input as {"credentials": {"is_admin": true}, "target": {"application_credential": {"user_id": "uid"}}} } test_system_reader_allowed if { - list.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"user_id": "uid"}} + list.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"application_credential": {"user_id": "uid"}}} } test_owner_allowed if { - list.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} + list.allow with input as {"credentials": {"user_id": "uid"}, "target": {"application_credential": {"user_id": "uid"}}} } test_non_owner_forbidden if { - not list.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"user_id": "uid"}} + not list.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_violation if { + v := list.violation with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + count(v) == 1 + some e in v + e.field == "user_id" } \ No newline at end of file diff --git a/policy/user/application_credential/show.rego b/policy/user/application_credential/show.rego index da7747a28..25d9f9d0b 100644 --- a/policy/user/application_credential/show.rego +++ b/policy/user/application_credential/show.rego @@ -11,4 +11,10 @@ allow if { input.credentials.system == "all" } -allow if { input.credentials.user_id == input.target.user_id } \ No newline at end of file +allow if { input.credentials.user_id == input.target.application_credential.user_id } + +violation contains {"field": "user_id", "msg": "viewing application credentials of a different user is not allowed."} if { + not input.credentials.is_admin + not input.credentials.system == "all" + input.credentials.user_id != input.target.application_credential.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 index af903d3f5..4f7916140 100644 --- a/policy/user/application_credential/show_test.rego +++ b/policy/user/application_credential/show_test.rego @@ -3,17 +3,24 @@ 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"}} + show.allow with input as {"credentials": {"is_admin": true}, "target": {"application_credential": {"user_id": "uid"}}} } test_system_reader_allowed if { - show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"user_id": "uid"}} + show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"application_credential": {"user_id": "uid"}}} } test_owner_allowed if { - show.allow with input as {"credentials": {"user_id": "uid"}, "target": {"user_id": "uid"}} + show.allow with input as {"credentials": {"user_id": "uid"}, "target": {"application_credential": {"user_id": "uid"}}} } test_non_owner_forbidden if { - not show.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"user_id": "uid"}} + not show.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} +} + +test_non_owner_violation if { + v := show.violation with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + count(v) == 1 + some e in v + e.field == "user_id" } \ No newline at end of file diff --git a/tests/api/src/identity/application_credential.rs b/tests/api/src/identity/application_credential.rs index 30bea1db5..6eefd0937 100644 --- a/tests/api/src/identity/application_credential.rs +++ b/tests/api/src/identity/application_credential.rs @@ -70,30 +70,48 @@ impl RestEndpoint for AppCredDeleteRequest { } } +pub struct DeletableApplicationCredential { + pub credential: ApplicationCredentialCreated, + pub user_id: String, +} + #[async_trait::async_trait] -impl DeletableResource for ApplicationCredentialCreated { +impl DeletableResource for DeletableApplicationCredential { async fn delete(&self, state: &Arc) -> Result<()> { Ok(openstack_sdk::api::ignore(AppCredDeleteRequest { user_id: self.user_id.clone(), - id: self.id.clone(), + id: self.credential.id.clone(), }) .query_async(state.as_ref()) .await?) } } +impl std::ops::Deref for DeletableApplicationCredential { + type Target = ApplicationCredentialCreated; + fn deref(&self) -> &Self::Target { + &self.credential + } +} + pub async fn create_application_credential( tc: &Arc, user_id: &str, app_cred: ApplicationCredentialCreate, -) -> Result> { +) -> 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())) + Ok(AsyncResourceGuard::new( + DeletableApplicationCredential { + credential: obj, + user_id: user_id.to_string(), + }, + tc.clone(), + )) } struct AppCredGetRequest { 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 3705b2126..4e06ca39d 100644 --- a/tests/api/tests/api_v3/identity/application_credential/create.rs +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -41,7 +41,7 @@ async fn test_create() -> Result<()> { assert_eq!(cred.name, "test-cred"); assert!(!cred.secret.is_empty()); - assert_eq!(cred.user_id, user_id); + // assert_eq!(cred.user_id, user_id); cred.delete().await?; 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 index a08e86d83..1af1eeda6 100644 --- a/tests/api/tests/api_v3/identity/application_credential/list.rs +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -69,20 +69,3 @@ async fn test_list() -> Result<()> { 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 index 8cbb7766b..efec489a5 100644 --- a/tests/api/tests/api_v3/identity/application_credential/show.rs +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -45,7 +45,7 @@ async fn test_show() -> Result<()> { assert_eq!(fetched.id, cred.id); assert_eq!(fetched.name, "test-cred"); - assert_eq!(fetched.user_id, user_id); + // assert_eq!(fetched.user_id, user_id); cred.delete().await?; Ok(()) From eb1244dd39ffc80fbaa17f429bc1288201033bf8 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Wed, 15 Jul 2026 23:12:01 +0000 Subject: [PATCH 06/12] fix: Update backend tests Signed-off-by: Hamza Konac --- .../src/application_credential/delete.rs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/crates/appcred-driver-sql/src/application_credential/delete.rs b/crates/appcred-driver-sql/src/application_credential/delete.rs index fb5367b95..6a5606a69 100644 --- a/crates/appcred-driver-sql/src/application_credential/delete.rs +++ b/crates/appcred-driver-sql/src/application_credential/delete.rs @@ -33,7 +33,11 @@ pub async fn delete( .exec(db) .await .context("deleting application credential")?; - Ok(()) + if res.rows_affected == 1 { + Ok(()) + } else { + Err(ApplicationCredentialProviderError::ApplicationCredentialNotFound(id.to_string())) + } } #[cfg(test)] mod tests { @@ -45,10 +49,6 @@ mod tests { #[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, @@ -59,25 +59,21 @@ mod tests { 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()] - ), - ] + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "application_credential" WHERE "application_credential"."id" = $1"#, + ["app_cred_id".into()] + )] ); } #[tokio::test] async fn test_delete_not_found() { let db = MockDatabase::new(DatabaseBackend::Postgres) - .append_query_results([Vec::::new()]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 0, + }]) .into_connection(); let result = delete(&db, "non-existing-id").await; From f6dd23125234b6322f909ffc760f4d3ab4a22ce6 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Thu, 16 Jul 2026 18:56:04 +0000 Subject: [PATCH 07/12] fix: Remove id from AppCredentialCreate Signed-off-by: Hamza Konac --- crates/api-types/src/error_conv.rs | 2 +- .../v3/application_credential/application_credential.rs | 7 ------- crates/api-types/src/v3/application_credential_conv.rs | 3 --- crates/keystone/src/api/v3/domain/mod.rs | 3 +-- .../tests/api_v3/identity/application_credential/create.rs | 2 +- .../tests/api_v3/identity/application_credential/list.rs | 4 ++-- .../tests/api_v3/identity/application_credential/show.rs | 1 - 7 files changed, 5 insertions(+), 17 deletions(-) diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 5fb15f7a4..537fcdf0c 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -279,7 +279,7 @@ impl From for KeystoneApiError { Self::BadRequest("application credential has expired".into()) } ApplicationCredentialProviderError::AccessRuleInUse(x) => { - Self::BadRequest(format!("access rule {x} is still in use")) + Self::Conflict("application credential access rule is in use".into()) } err @ ApplicationCredentialProviderError::AccessRulesUnenforced => { Self::BadRequest(err.to_string()) 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 9310078a4..2a56d5ba6 100644 --- a/crates/api-types/src/v3/application_credential/application_credential.rs +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -111,13 +111,6 @@ pub struct ApplicationCredentialCreate { #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, - /// Optional client-supplied identifier for the application credential. If - /// not provided, the server generates one. - #[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, - /// Name of the application credential. Must be unique among the owning /// user's application credentials. #[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))] diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs index 5c2e09929..78f4f51dd 100644 --- a/crates/api-types/src/v3/application_credential_conv.rs +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -92,9 +92,6 @@ impl From if let Some(v) = value.expires_at { builder.expires_at(v); } - if let Some(v) = value.id { - builder.id(v); - } if let Some(v) = value.unrestricted { builder.unrestricted(v); } diff --git a/crates/keystone/src/api/v3/domain/mod.rs b/crates/keystone/src/api/v3/domain/mod.rs index 8ff6ae55d..b34e41386 100644 --- a/crates/keystone/src/api/v3/domain/mod.rs +++ b/crates/keystone/src/api/v3/domain/mod.rs @@ -28,8 +28,7 @@ mod update; #[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/tests/api/tests/api_v3/identity/application_credential/create.rs b/tests/api/tests/api_v3/identity/application_credential/create.rs index 4e06ca39d..3705b2126 100644 --- a/tests/api/tests/api_v3/identity/application_credential/create.rs +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -41,7 +41,7 @@ async fn test_create() -> Result<()> { assert_eq!(cred.name, "test-cred"); assert!(!cred.secret.is_empty()); - // assert_eq!(cred.user_id, user_id); + assert_eq!(cred.user_id, user_id); cred.delete().await?; 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 index 1af1eeda6..5c31c0502 100644 --- a/tests/api/tests/api_v3/identity/application_credential/list.rs +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -45,7 +45,7 @@ async fn test_list() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name(format!("cred-1")) + .name("cred-1") .roles(vec![]) .build()?, ) @@ -55,7 +55,7 @@ async fn test_list() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name(format!("cred-2")) + .name("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 efec489a5..48ac0fa64 100644 --- a/tests/api/tests/api_v3/identity/application_credential/show.rs +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -45,7 +45,6 @@ async fn test_show() -> Result<()> { assert_eq!(fetched.id, cred.id); assert_eq!(fetched.name, "test-cred"); - // assert_eq!(fetched.user_id, user_id); cred.delete().await?; Ok(()) From 275b607aa6cc792cc94f31ea13c036103bb73617 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Sat, 18 Jul 2026 20:30:56 +0000 Subject: [PATCH 08/12] fix: Fix mismatch policy enforcer calling Signed-off-by: Hamza Konac --- crates/api-types/src/error_conv.rs | 2 +- .../application_credential.rs | 15 +++++++++++++-- .../src/v3/application_credential_conv.rs | 15 ++++++++++++++- .../api/v3/user/application_credential/create.rs | 4 ++-- .../api/v3/user/application_credential/delete.rs | 4 ++-- .../api/v3/user/application_credential/show.rs | 4 ++-- policy/user/application_credential/delete.rego | 4 ++-- .../user/application_credential/delete_test.rego | 8 ++++---- policy/user/application_credential/list.rego | 5 +++++ policy/user/application_credential/list_test.rego | 12 ++++++++++++ policy/user/application_credential/show.rego | 4 ++-- policy/user/application_credential/show_test.rego | 10 +++++----- .../identity/application_credential/create.rs | 3 ++- 13 files changed, 66 insertions(+), 24 deletions(-) diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 537fcdf0c..a9a76a1a0 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -278,7 +278,7 @@ impl From for KeystoneApiError { ApplicationCredentialProviderError::ApplicationCredentialExpired => { Self::BadRequest("application credential has expired".into()) } - ApplicationCredentialProviderError::AccessRuleInUse(x) => { + ApplicationCredentialProviderError::AccessRuleInUse(_) => { Self::Conflict("application credential access rule is in use".into()) } err @ ApplicationCredentialProviderError::AccessRulesUnenforced => { 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 2a56d5ba6..08a170e5e 100644 --- a/crates/api-types/src/v3/application_credential/application_credential.rs +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -14,6 +14,7 @@ //! # Application credential API types use chrono::{DateTime, Utc}; +use secrecy::SecretString; use serde::{Deserialize, Serialize}; #[cfg(feature = "validate")] use validator::Validate; @@ -151,7 +152,7 @@ pub struct ApplicationCredentialCreateRequest { } /// Application credential as returned by create — includes secret (shown once only). -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct ApplicationCredentialCreated { @@ -183,7 +184,9 @@ pub struct ApplicationCredentialCreated { /// The secret used for authentication. The secret is hashed before storage, /// so this is the only time it is returned in plaintext. If lost, a new /// application credential must be created. - pub secret: String, + #[serde(serialize_with = "serialize_secret")] + #[cfg_attr(feature = "openapi", schema(value_type = String))] + pub secret: SecretString, /// Whether this credential has unrestricted access. pub unrestricted: bool, @@ -219,3 +222,11 @@ pub struct ApplicationCredentialListParameters { #[cfg_attr(feature = "validate", validate(length(max = 255)))] pub name: Option, } + +fn serialize_secret(secret: &SecretString, serializer: S) -> Result +where + S: serde::Serializer, +{ + use secrecy::ExposeSecret; + serializer.serialize_str(secret.expose_secret()) +} diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs index 78f4f51dd..f4e280bb2 100644 --- a/crates/api-types/src/v3/application_credential_conv.rs +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -62,7 +62,7 @@ impl From name: value.name, project_id: value.project_id, roles: value.roles.into_iter().map(Into::into).collect(), - secret: value.secret.expose_secret().to_string(), + secret: value.secret.expose_secret().to_string().into(), unrestricted: value.unrestricted, } } @@ -129,3 +129,16 @@ impl From builder } } + +impl PartialEq for api_types_application_credential::ApplicationCredentialCreated { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.name == other.name + && self.project_id == other.project_id + && self.unrestricted == other.unrestricted + && self.roles == other.roles + && self.description == other.description + && self.expires_at == other.expires_at + && self.access_rules == other.access_rules + } +} 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 893a05083..fa88bbe45 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -122,7 +122,7 @@ mod tests { use openstack_keystone_core_types::application_credential::ApplicationCredentialCreateResponseBuilder; use openstack_keystone_core_types::identity::*; - use secrecy::SecretString; + use secrecy::{ExposeSecret, SecretString}; use crate::api::tests::{get_mocked_state, test_fixture_scoped}; use crate::api::v3::openapi_router; @@ -218,7 +218,7 @@ mod tests { 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()); + assert!(!res.application_credential.secret.expose_secret().is_empty()); } #[traced_test] 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 efbfdfaf9..d4e58d55d 100644 --- a/crates/keystone/src/api/v3/user/application_credential/delete.rs +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -59,8 +59,8 @@ pub(super) async fn delete( .enforce( "identity/user/application_credential/delete", &user_auth, - json!({"application_credential": serde_json::to_value(¤t)?}), - None, + json!({"application_credential": null}), + Some(json!({"application_credential": serde_json::to_value(¤t)?})), ) .await?; 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 665cf83cb..180133745 100644 --- a/crates/keystone/src/api/v3/user/application_credential/show.rs +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -62,8 +62,8 @@ pub(super) async fn show( .enforce( "identity/user/application_credential/show", &user_auth, - json!({"application_credential": serde_json::to_value(¤t)?}), - None, + json!({"application_credential": null}), + Some(json!({"application_credential": serde_json::to_value(¤t)?})), ) .await?; diff --git a/policy/user/application_credential/delete.rego b/policy/user/application_credential/delete.rego index 9f4d58b56..c1e6cca89 100644 --- a/policy/user/application_credential/delete.rego +++ b/policy/user/application_credential/delete.rego @@ -6,9 +6,9 @@ default allow := false allow if { input.credentials.is_admin } -allow if { input.credentials.user_id == input.target.application_credential.user_id } +allow if { input.credentials.user_id == input.existing.application_credential.user_id } violation contains {"field": "user_id", "msg": "deleting application credentials of a different user is not allowed."} if { not input.credentials.is_admin - input.credentials.user_id != input.target.application_credential.user_id + input.credentials.user_id != input.existing.application_credential.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 index a8fe45903..a0d88ee69 100644 --- a/policy/user/application_credential/delete_test.rego +++ b/policy/user/application_credential/delete_test.rego @@ -3,19 +3,19 @@ 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": {"application_credential": {"user_id": "uid"}}} + delete.allow with input as {"credentials": {"is_admin": true}, "existing": {"application_credential": {"user_id": "uid"}}} } test_owner_allowed if { - delete.allow with input as {"credentials": {"user_id": "uid"}, "target": {"application_credential": {"user_id": "uid"}}} + delete.allow with input as {"credentials": {"user_id": "uid"}, "existing": {"application_credential": {"user_id": "uid"}}} } test_non_owner_forbidden if { - not delete.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + not delete.allow with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} } test_non_owner_violation if { - v := delete.violation with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + v := delete.violation with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} count(v) == 1 some e in v e.field == "user_id" diff --git a/policy/user/application_credential/list.rego b/policy/user/application_credential/list.rego index d8ef5b5c1..29918a96b 100644 --- a/policy/user/application_credential/list.rego +++ b/policy/user/application_credential/list.rego @@ -13,7 +13,12 @@ allow if { allow if { input.credentials.user_id == input.target.application_credential.user_id } +violation contains {"field": "user_id", "msg": "listing application credentials requires a user_id."} if { + not input.target.application_credential.user_id +} + violation contains {"field": "user_id", "msg": "listing application credentials of a different user is not allowed."} if { + input.target.application_credential.user_id not input.credentials.is_admin not input.credentials.system == "all" input.credentials.user_id != input.target.application_credential.user_id diff --git a/policy/user/application_credential/list_test.rego b/policy/user/application_credential/list_test.rego index a0d19bd87..4408c208a 100644 --- a/policy/user/application_credential/list_test.rego +++ b/policy/user/application_credential/list_test.rego @@ -23,4 +23,16 @@ test_non_owner_violation if { count(v) == 1 some e in v e.field == "user_id" + e.msg == "listing application credentials of a different user is not allowed." +} + +test_missing_user_id_forbidden if { + not list.allow with input as {"credentials": {"user_id": "uid", "roles": []}, "target": {"application_credential": {}}} +} + +test_missing_user_id_violation if { + v := list.violation with input as {"credentials": {"user_id": "uid", "roles": []}, "target": {"application_credential": {}}} + some e in v + e.field == "user_id" + e.msg == "listing application credentials requires a user_id." } \ No newline at end of file diff --git a/policy/user/application_credential/show.rego b/policy/user/application_credential/show.rego index 25d9f9d0b..beecf37f5 100644 --- a/policy/user/application_credential/show.rego +++ b/policy/user/application_credential/show.rego @@ -11,10 +11,10 @@ allow if { input.credentials.system == "all" } -allow if { input.credentials.user_id == input.target.application_credential.user_id } +allow if { input.credentials.user_id == input.existing.application_credential.user_id } violation contains {"field": "user_id", "msg": "viewing application credentials of a different user is not allowed."} if { not input.credentials.is_admin not input.credentials.system == "all" - input.credentials.user_id != input.target.application_credential.user_id + input.credentials.user_id != input.existing.application_credential.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 index 4f7916140..7cce633cf 100644 --- a/policy/user/application_credential/show_test.rego +++ b/policy/user/application_credential/show_test.rego @@ -3,23 +3,23 @@ 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": {"application_credential": {"user_id": "uid"}}} + show.allow with input as {"credentials": {"is_admin": true}, "existing": {"application_credential": {"user_id": "uid"}}} } test_system_reader_allowed if { - show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "target": {"application_credential": {"user_id": "uid"}}} + show.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}, "existing": {"application_credential": {"user_id": "uid"}}} } test_owner_allowed if { - show.allow with input as {"credentials": {"user_id": "uid"}, "target": {"application_credential": {"user_id": "uid"}}} + show.allow with input as {"credentials": {"user_id": "uid"}, "existing": {"application_credential": {"user_id": "uid"}}} } test_non_owner_forbidden if { - not show.allow with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + not show.allow with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} } test_non_owner_violation if { - v := show.violation with input as {"credentials": {"user_id": "other", "roles": []}, "target": {"application_credential": {"user_id": "uid"}}} + v := show.violation with input as {"credentials": {"user_id": "other", "roles": []}, "existing": {"application_credential": {"user_id": "uid"}}} count(v) == 1 some e in v e.field == "user_id" 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 3705b2126..29d0f96bb 100644 --- a/tests/api/tests/api_v3/identity/application_credential/create.rs +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -14,6 +14,7 @@ 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 secrecy::ExposeSecret; use test_api::guard::ResourceGuard; use test_api::identity::application_credential::create_application_credential; use tracing_test::traced_test; @@ -40,7 +41,7 @@ async fn test_create() -> Result<()> { .await?; assert_eq!(cred.name, "test-cred"); - assert!(!cred.secret.is_empty()); + assert!(!cred.secret.expose_secret().is_empty()); assert_eq!(cred.user_id, user_id); cred.delete().await?; From c9c7c3b1bd297f214af1c3053f5d0a303b2bbc1b Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Mon, 20 Jul 2026 21:53:05 +0000 Subject: [PATCH 09/12] fix: Add secret into ApplicationCredentialCreate Signed-off-by: Hamza Konac --- .../application_credential.rs | 26 ++++++++++++++++++- .../src/v3/application_credential_conv.rs | 15 +++++++++++ .../v3/user/application_credential/create.rs | 5 +++- .../v3/user/application_credential/delete.rs | 2 +- .../v3/user/application_credential/show.rs | 2 +- 5 files changed, 46 insertions(+), 4 deletions(-) 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 08a170e5e..11f1be7fa 100644 --- a/crates/api-types/src/v3/application_credential/application_credential.rs +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -82,7 +82,7 @@ pub struct ApplicationCredential { } /// Data for creating an application credential. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -123,6 +123,16 @@ pub struct ApplicationCredentialCreate { #[cfg_attr(feature = "builder", builder(default))] pub roles: Vec, + /// Optional secret to use for the new credential. If not provided, a + /// random secret will be generated. + #[cfg_attr(feature = "builder", builder(default))] + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_secret" + )] + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + pub secret: Option, + /// Whether to allow unrestricted access. When `true`, the credential can /// create additional application credentials or trusts, which is /// potentially dangerous. Defaults to `false`. @@ -230,3 +240,17 @@ where use secrecy::ExposeSecret; serializer.serialize_str(secret.expose_secret()) } + +fn serialize_optional_secret( + secret: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + use secrecy::ExposeSecret; + match secret { + Some(s) => serializer.serialize_str(s.expose_secret()), + None => serializer.serialize_none(), + } +} diff --git a/crates/api-types/src/v3/application_credential_conv.rs b/crates/api-types/src/v3/application_credential_conv.rs index f4e280bb2..c57dde33b 100644 --- a/crates/api-types/src/v3/application_credential_conv.rs +++ b/crates/api-types/src/v3/application_credential_conv.rs @@ -95,6 +95,9 @@ impl From if let Some(v) = value.unrestricted { builder.unrestricted(v); } + if let Some(v) = value.secret { + builder.secret(v); + } builder } } @@ -142,3 +145,15 @@ impl PartialEq for api_types_application_credential::ApplicationCredentialCreate && self.access_rules == other.access_rules } } + +impl PartialEq for api_types_application_credential::ApplicationCredentialCreate { + fn eq(&self, other: &Self) -> bool { + self.access_rules == other.access_rules + && self.description == other.description + && self.expires_at == other.expires_at + && self.name == other.name + && self.roles == other.roles + && self.unrestricted == other.unrestricted + // secret is intentionally excluded — SecretBox does not implement PartialEq + } +} 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 fa88bbe45..ad4f80e3d 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -68,13 +68,16 @@ pub(super) async fn create( let mut target = serde_json::to_value(&payload.application_credential)?; target["user_id"] = json!(user_id); + // Check payload has secret field,if yes copy target variable and remove secret from copied variable and pass it to policy enforcer + let mut target_for_policy = target.clone(); + target_for_policy.as_object_mut().unwrap().remove("secret"); state .policy_enforcer .enforce( "identity/user/application_credential/create", &user_auth, - json!({"application_credential": target}), + json!({"application_credential": target_for_policy}), None, ) .await?; 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 d4e58d55d..6b3fd54aa 100644 --- a/crates/keystone/src/api/v3/user/application_credential/delete.rs +++ b/crates/keystone/src/api/v3/user/application_credential/delete.rs @@ -59,7 +59,7 @@ pub(super) async fn delete( .enforce( "identity/user/application_credential/delete", &user_auth, - json!({"application_credential": null}), + serde_json::Value::Null, Some(json!({"application_credential": serde_json::to_value(¤t)?})), ) .await?; 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 180133745..4842c60d7 100644 --- a/crates/keystone/src/api/v3/user/application_credential/show.rs +++ b/crates/keystone/src/api/v3/user/application_credential/show.rs @@ -62,7 +62,7 @@ pub(super) async fn show( .enforce( "identity/user/application_credential/show", &user_auth, - json!({"application_credential": null}), + serde_json::Value::Null, Some(json!({"application_credential": serde_json::to_value(¤t)?})), ) .await?; From 2a398817054774a91aa63560e5d5a628c10c2393 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Thu, 30 Jul 2026 20:39:52 +0000 Subject: [PATCH 10/12] fix: Fix small nits Signed-off-by: Hamza Konac --- .../application_credential.rs | 28 ++----------------- .../v3/user/application_credential/create.rs | 4 ++- 2 files changed, 6 insertions(+), 26 deletions(-) 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 11f1be7fa..2f87389ae 100644 --- a/crates/api-types/src/v3/application_credential/application_credential.rs +++ b/crates/api-types/src/v3/application_credential/application_credential.rs @@ -19,9 +19,9 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "validate")] use validator::Validate; +use crate::common; use crate::v3::application_credential::access_rule::{AccessRule, AccessRuleCreate}; use crate::v3::role::RoleRef; - /// Full application credential representation. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[cfg_attr( @@ -128,7 +128,7 @@ pub struct ApplicationCredentialCreate { #[cfg_attr(feature = "builder", builder(default))] #[serde( skip_serializing_if = "Option::is_none", - serialize_with = "serialize_optional_secret" + serialize_with = "common::serialize_optional_secret" )] #[cfg_attr(feature = "openapi", schema(value_type = Option))] pub secret: Option, @@ -194,7 +194,7 @@ pub struct ApplicationCredentialCreated { /// The secret used for authentication. The secret is hashed before storage, /// so this is the only time it is returned in plaintext. If lost, a new /// application credential must be created. - #[serde(serialize_with = "serialize_secret")] + #[serde(serialize_with = "common::serialize_secret_string")] #[cfg_attr(feature = "openapi", schema(value_type = String))] pub secret: SecretString, @@ -232,25 +232,3 @@ pub struct ApplicationCredentialListParameters { #[cfg_attr(feature = "validate", validate(length(max = 255)))] pub name: Option, } - -fn serialize_secret(secret: &SecretString, serializer: S) -> Result -where - S: serde::Serializer, -{ - use secrecy::ExposeSecret; - serializer.serialize_str(secret.expose_secret()) -} - -fn serialize_optional_secret( - secret: &Option, - serializer: S, -) -> Result -where - S: serde::Serializer, -{ - use secrecy::ExposeSecret; - match secret { - Some(s) => serializer.serialize_str(s.expose_secret()), - None => serializer.serialize_none(), - } -} 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 ad4f80e3d..aad789ec5 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -70,7 +70,9 @@ pub(super) async fn create( target["user_id"] = json!(user_id); // Check payload has secret field,if yes copy target variable and remove secret from copied variable and pass it to policy enforcer let mut target_for_policy = target.clone(); - target_for_policy.as_object_mut().unwrap().remove("secret"); + if let Some(obj) = target_for_policy.as_object_mut() { + obj.remove("secret"); + } state .policy_enforcer From 35bbaa65a2f0ea0f4670e52b9ce9ac409bdb3ccc Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Mon, 3 Aug 2026 19:23:01 +0000 Subject: [PATCH 11/12] fix: Remove unwrap Signed-off-by: Hamza Konac --- .../keystone/src/api/v3/user/application_credential/create.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 aad789ec5..093897571 100644 --- a/crates/keystone/src/api/v3/user/application_credential/create.rs +++ b/crates/keystone/src/api/v3/user/application_credential/create.rs @@ -89,8 +89,7 @@ pub(super) async fn create( ) .user_id(user_id.clone()) .project_id(project_id) - .build() - .unwrap(); + .build()?; // Verify user exists — 404 if not found state From 022b1183d04893b10fd3553abda7e8fee04efb28 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Tue, 4 Aug 2026 23:16:54 +0000 Subject: [PATCH 12/12] fix: Give unique name to appcreds in Api tests Signed-off-by: Hamza Konac --- .../tests/api_v3/identity/application_credential/create.rs | 6 +++--- .../tests/api_v3/identity/application_credential/delete.rs | 2 +- .../tests/api_v3/identity/application_credential/list.rs | 4 ++-- .../tests/api_v3/identity/application_credential/show.rs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) 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 29d0f96bb..385b4e7ca 100644 --- a/tests/api/tests/api_v3/identity/application_credential/create.rs +++ b/tests/api/tests/api_v3/identity/application_credential/create.rs @@ -34,13 +34,13 @@ async fn test_create() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name("test-cred") + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) .roles(vec![]) .build()?, ) .await?; - assert_eq!(cred.name, "test-cred"); + assert!(cred.name.starts_with("test-cred-")); assert!(!cred.secret.expose_secret().is_empty()); assert_eq!(cred.user_id, user_id); @@ -63,7 +63,7 @@ async fn test_create_with_description() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name("test-cred") + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) .description("my description") .roles(vec![]) .build()?, 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 a4789821d..a99c8d89a 100644 --- a/tests/api/tests/api_v3/identity/application_credential/delete.rs +++ b/tests/api/tests/api_v3/identity/application_credential/delete.rs @@ -35,7 +35,7 @@ async fn test_delete() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name("test-cred") + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) .roles(vec![]) .build()?, ) 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 5c31c0502..e74aadea7 100644 --- a/tests/api/tests/api_v3/identity/application_credential/list.rs +++ b/tests/api/tests/api_v3/identity/application_credential/list.rs @@ -45,7 +45,7 @@ async fn test_list() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name("cred-1") + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) .roles(vec![]) .build()?, ) @@ -55,7 +55,7 @@ async fn test_list() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name("cred-2") + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) .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 48ac0fa64..efe894a43 100644 --- a/tests/api/tests/api_v3/identity/application_credential/show.rs +++ b/tests/api/tests/api_v3/identity/application_credential/show.rs @@ -35,7 +35,7 @@ async fn test_show() -> Result<()> { &tc, &user_id, ApplicationCredentialCreateBuilder::default() - .name("test-cred") + .name(&format!("test-cred-{}", uuid::Uuid::new_v4().simple())) .roles(vec![]) .build()?, ) @@ -44,7 +44,7 @@ async fn test_show() -> Result<()> { let fetched = get_application_credential(&tc, &user_id, &cred.id).await?; assert_eq!(fetched.id, cred.id); - assert_eq!(fetched.name, "test-cred"); + assert!(fetched.name.starts_with("test-cred")); cred.delete().await?; Ok(())