From aa92966baf1cdfdf42ab01ba569797bcba0bc193 Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 23 Jul 2026 18:20:11 -0400 Subject: [PATCH 1/9] test(api): Add v3 test infrastructure Add the shared building blocks the remaining #993 v3 suites rely on: - `test_api::fixtures::ProjectScopedUser`, a real password user holding a bootstrap role on a fresh project, authenticated with a genuinely project-scoped token through the live auth path. It is provisioned and torn down with the caller's admin session, so cleanup never depends on the underprivileged session, and it unwinds its own partial work if a later provisioning step fails. - `warn_on_cleanup_failure`, which logs a failed best-effort cleanup instead of shadowing the error that triggered it. - `test_api::common::raw_request`, for the invalid-auth leg of the matrix: the SDK refuses to send a syntactically invalid token, so those cases have to bypass it. Teach `asserts::status_from_error` to unwrap a `reqwest::Error` so the `raw_request` responses can go through `assert_unauthorized` and report the full error chain like every other negative assertion. Migrate the #992 group suite onto these helpers: its local `ProjectScopedManager` and `status_with_invalid_token` were the prototypes the shared versions generalize, and keeping both would let them drift apart. Signed-off-by: ShJ-code --- tests/api/README.md | 44 ++++++- tests/api/src/asserts.rs | 17 +++ tests/api/src/common.rs | 25 ++++ tests/api/src/fixtures.rs | 129 ++++++++++++++++++++ tests/api/src/lib.rs | 1 + tests/api/tests/api_v3/identity/group.rs | 142 ++++++----------------- 6 files changed, 245 insertions(+), 113 deletions(-) create mode 100644 tests/api/src/fixtures.rs diff --git a/tests/api/README.md b/tests/api/README.md index f191407db..7b5277d20 100644 --- a/tests/api/README.md +++ b/tests/api/README.md @@ -46,22 +46,58 @@ extracted status and the complete error chain. A denied-policy test is a one-liner on top of a scoped session: ```rust,ignore -let manager = ProjectScopedManager::provision(&admin, &domain.id).await?; +let manager = ProjectScopedUser::provision(&admin, &domain.id, "manager").await?; assert_forbidden( create_group(&manager.session, group_create(&domain.id)?).await, "manager role without domain scope must not create groups", ); ``` +On an *unexpected success* the helpers print the `Ok` value with `{:#?}`, so +never hand them a value whose `Debug` carries a secret (`Ec2Credential` +carries the plaintext secret, for instance) — map it down to a +non-sensitive field first. + +### Fixtures (`test_api::fixtures`) + +```rust,ignore +// A real password user holding `role_name` on a fresh project in `domain_id`, +// authenticated with a project-scoped token through the live auth path: +let member = ProjectScopedUser::provision(&admin, "default", "member").await?; +member.session; member.user.id; member.project.id; +member.cleanup().await?; + +// Same, but with the role granted on `system: all`: +let reader = SystemScopedUser::provision(&admin, "default", "reader").await?; +reader.cleanup().await?; +``` + +Both are provisioned *and* torn down with the admin session passed in, so +cleanup never depends on the underprivileged session, and both undo their +partial work if a later provisioning step fails. When you compose fixtures +yourself, mirror that: on error, release what you already hold with +`warn_on_cleanup_failure("…", fixture.cleanup().await)`, which logs a failed +best-effort cleanup instead of shadowing the error that triggered it. + +### Invalid-auth requests (`test_api::common::raw_request`) + +The SDK refuses to send a syntactically invalid token, so the 401 leg of the +matrix bypasses it: + +```rust,ignore +let rsp = raw_request(http::Method::GET, "v3/groups", Some("invalid-token"), None).await?; +assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); +``` + ### CRUD endpoint boilerplate (`test_api::macros::crud_endpoint`) Crate-private macro generating request structs, `RestEndpoint` impls and public wrapper functions for the common create/show/update/list/delete shapes — see the module docs in `src/macros.rs` for the invocation syntax and `src/identity/group.rs` for a complete example. Operations are -selectable and all names are explicit (no identifier generation). Endpoints -that do not fit (sub-resources, grants, borrowed fields) keep hand-written -impls. +selectable, sub-resources are supported via `parent`, and all names are +explicit (no identifier generation). Endpoints that still do not fit (grants, +borrowed fields) keep hand-written impls. ### Resource cleanup (`test_api::guard`) diff --git a/tests/api/src/asserts.rs b/tests/api/src/asserts.rs index 4914d527e..3ef62c050 100644 --- a/tests/api/src/asserts.rs +++ b/tests/api/src/asserts.rs @@ -32,12 +32,18 @@ use openstack_sdk::{OpenStackError, RestError, api::ApiError}; /// [`ApiError::OpenStackUnrecognized`] (direct API errors), /// - [`OpenStackError::Api`] (session-level wrapper around an [`ApiError`]), /// - [`OpenStackError::Http`] (auth/session HTTP errors). +/// - [`reqwest::Error`] (raw HTTP helpers after `error_for_status`). /// /// Both enums are `#[non_exhaustive]`; any variant that does not carry an /// HTTP status — including variants added by future SDK versions — yields /// `None`. pub fn status_from_error(report: &Report) -> Option { for cause in report.chain() { + if let Some(reqwest_err) = cause.downcast_ref::() + && let Some(status) = reqwest_err.status() + { + return Some(status); + } if let Some(api_err) = cause.downcast_ref::>() && let Some(status) = status_from_api_error(api_err) { @@ -198,6 +204,17 @@ mod tests { assert_forbidden(result, "must accept 403"); } + #[test] + fn assert_forbidden_accepts_reqwest_error() -> eyre::Result<()> { + let response = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::FORBIDDEN) + .body("")?, + ); + assert_forbidden(response.error_for_status(), "must accept raw HTTP 403"); + Ok(()) + } + #[test] #[should_panic(expected = "expected HTTP 403")] fn assert_forbidden_panics_on_success() { diff --git a/tests/api/src/common.rs b/tests/api/src/common.rs index e87dfcecd..17cee66e6 100644 --- a/tests/api/src/common.rs +++ b/tests/api/src/common.rs @@ -446,6 +446,31 @@ pub async fn session_for_config(config: &CloudConfig) -> Result, + body: Option, +) -> Result { + let base_url: Url = env::var("KEYSTONE_URL") + .wrap_err("KEYSTONE_URL must be set")? + .parse()?; + let mut request = Client::new().request(method, base_url.join(path)?); + if let Some(token) = token { + request = request.header("x-auth-token", token); + } + if let Some(body) = body { + request = request.json(&body); + } + Ok(request.send().await?) +} + /// Admin session scoped to the given domain (see [`get_domain_scope_config`]). pub async fn get_domain_scope_session(domain_id: &str) -> Result> { session_for_config(&get_domain_scope_config(domain_id)?).await diff --git a/tests/api/src/fixtures.rs b/tests/api/src/fixtures.rs new file mode 100644 index 000000000..5e20d5fcc --- /dev/null +++ b/tests/api/src/fixtures.rs @@ -0,0 +1,129 @@ +// 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 +//! Reusable live-API fixtures for authorization-matrix tests. + +use std::sync::Arc; + +use eyre::{OptionExt, Result}; +use uuid::Uuid; + +use openstack_keystone_api_types::scope::{DomainBuilder, Scope, ScopeProjectBuilder}; +use openstack_keystone_api_types::v3::project::{Project, ProjectCreateBuilder}; +use openstack_keystone_api_types::v3::user::{User, UserCreateBuilder}; +use openstack_sdk::AsyncOpenStack; + +use crate::assignment::grant::add_project_grant; +use crate::common::get_user_session; +use crate::guard::{AsyncResourceGuard, ResourceGuard}; +use crate::identity::user::create_user; +use crate::resource::project::create_project; +use crate::role::list_roles; + +/// Password used for all fixture users. +pub const FIXTURE_PASSWORD: &str = "fixture-user-password"; + +/// Report a failed best-effort cleanup without discarding the error that +/// triggered it — the original failure is the one worth diagnosing. +pub fn warn_on_cleanup_failure(what: &str, result: Result<()>) { + if let Err(error) = result { + tracing::warn!("failed to clean up {what} after an error: {error:?}"); + } +} + +/// A real user provisioned through the live API holding a bootstrap role +/// (e.g. `member` or `manager`) on a fresh project in `domain_id`, +/// authenticated with a **project-scoped** token through the real +/// password-auth path. +/// +/// All fixture resources are created by — and cleaned up with — the admin +/// session passed to [`Self::provision`], so cleanup never depends on the +/// underprivileged session. +pub struct ProjectScopedUser { + /// Project-scoped session authenticated as the fixture user. + pub session: Arc, + /// The fixture user (guarded; deleted by [`Self::cleanup`]). + pub user: AsyncResourceGuard, + /// The fixture project (guarded; deleted by [`Self::cleanup`]). + pub project: AsyncResourceGuard, +} + +impl ProjectScopedUser { + /// Provision a user with `role_name` granted on a fresh project in + /// `domain_id`. + pub async fn provision( + admin: &Arc, + domain_id: &str, + role_name: &str, + ) -> Result { + let unique = Uuid::new_v4().simple().to_string(); + let project_create = ProjectCreateBuilder::default() + .name(format!("fix-proj-{unique}")) + .domain_id(domain_id) + .build()?; + let user_create = UserCreateBuilder::default() + .name(format!("fix-usr-{unique}")) + .domain_id(domain_id) + .password(FIXTURE_PASSWORD) + .enabled(true) + .build()?; + + let project = create_project(admin, project_create).await?; + let user_result = create_user(admin, user_create).await; + let user = match user_result { + Ok(user) => user, + Err(error) => { + warn_on_cleanup_failure("fixture project", project.delete().await); + return Err(error); + } + }; + let session_result = async { + let role = list_roles(admin) + .await? + .into_iter() + .find(|role| role.name == role_name) + .ok_or_eyre(format!("bootstrap `{role_name}` role must exist"))?; + add_project_grant(admin, &project.id, &user.id, &role.id).await?; + + let scope = Scope::Project( + ScopeProjectBuilder::default() + .id(project.id.clone()) + .domain(DomainBuilder::default().id(domain_id).build()?) + .build()?, + ); + get_user_session(&user.name, FIXTURE_PASSWORD, domain_id, Some(&scope)).await + } + .await; + + match session_result { + Ok(session) => Ok(Self { + session, + user, + project, + }), + Err(error) => { + warn_on_cleanup_failure("fixture user", user.delete().await); + warn_on_cleanup_failure("fixture project", project.delete().await); + Err(error) + } + } + } + + /// Delete the fixture user and project with the admin session that + /// created them. + pub async fn cleanup(self) -> Result<()> { + self.user.delete().await?; + self.project.delete().await?; + Ok(()) + } +} diff --git a/tests/api/src/lib.rs b/tests/api/src/lib.rs index ab62766c7..499f9db67 100644 --- a/tests/api/src/lib.rs +++ b/tests/api/src/lib.rs @@ -19,6 +19,7 @@ pub mod common; pub mod credential; pub mod endpoint; pub mod federation; +pub mod fixtures; pub mod guard; pub mod identity; pub mod macros; diff --git a/tests/api/tests/api_v3/identity/group.rs b/tests/api/tests/api_v3/identity/group.rs index 013d64df9..43c2676f4 100644 --- a/tests/api/tests/api_v3/identity/group.rs +++ b/tests/api/tests/api_v3/identity/group.rs @@ -41,31 +41,22 @@ //! domain-scoped token with those roles. Tracked as a coverage gap for //! Phase 2 (#993). -use std::env; use std::sync::Arc; -use eyre::{OptionExt, Result, WrapErr}; +use eyre::Result; use uuid::Uuid; -use openstack_keystone_api_types::scope::{DomainBuilder, Scope, ScopeProjectBuilder}; use openstack_keystone_api_types::v3::domain::DomainCreateBuilder; use openstack_keystone_api_types::v3::group::*; -use openstack_keystone_api_types::v3::project::{Project, ProjectCreateBuilder}; -use openstack_keystone_api_types::v3::user::{User, UserCreateBuilder}; use openstack_sdk::AsyncOpenStack; -use test_api::asserts::{assert_forbidden, assert_status}; -use test_api::assignment::grant::add_project_grant; -use test_api::common::get_user_session; +use test_api::asserts::{assert_forbidden, assert_status, assert_unauthorized}; +use test_api::common::raw_request; +use test_api::fixtures::ProjectScopedUser; use test_api::guard::{AsyncResourceGuard, ResourceGuard}; use test_api::identity::group::*; -use test_api::identity::user::create_user; use test_api::resource::domain::create_domain; use test_api::resource::get_system_scope_config; -use test_api::resource::project::create_project; -use test_api::role::list_roles; - -const FIXTURE_PASSWORD: &str = "group-fixture-password"; /// System-scoped admin session for fixture management — the same /// convention as the v3 domain tests, which also create domains. @@ -96,85 +87,6 @@ fn group_create(domain_id: &str) -> Result { .build()?) } -/// A real user in `domain_id` holding the `manager` role (implies -/// `member`/`reader`) on a project in the same domain, authenticated with -/// a **project-scoped** token through the live password-auth path. All -/// fixture resources are created by — and cleaned up with — the admin -/// session, so cleanup never depends on the underprivileged session. -struct ProjectScopedManager { - session: Arc, - user: AsyncResourceGuard, - project: AsyncResourceGuard, -} - -impl ProjectScopedManager { - async fn provision(admin: &Arc, domain_id: &str) -> Result { - let unique = Uuid::new_v4().simple().to_string(); - let project = create_project( - admin, - ProjectCreateBuilder::default() - .name(format!("grp-proj-{unique}")) - .domain_id(domain_id) - .build()?, - ) - .await?; - let user = create_user( - admin, - UserCreateBuilder::default() - .name(format!("grp-mgr-{unique}")) - .domain_id(domain_id) - .password(FIXTURE_PASSWORD) - .enabled(true) - .build()?, - ) - .await?; - let manager_role = list_roles(admin) - .await? - .into_iter() - .find(|role| role.name == "manager") - .ok_or_eyre("bootstrap `manager` role must exist")?; - add_project_grant(admin, &project.id, &user.id, &manager_role.id).await?; - - let scope = Scope::Project( - ScopeProjectBuilder::default() - .id(project.id.clone()) - .domain(DomainBuilder::default().id(domain_id).build()?) - .build()?, - ); - let session = - get_user_session(&user.name, FIXTURE_PASSWORD, domain_id, Some(&scope)).await?; - Ok(Self { - session, - user, - project, - }) - } - - async fn cleanup(self) -> Result<()> { - self.user.delete().await?; - self.project.delete().await?; - Ok(()) - } -} - -/// Raw request with an invalid token; returns the response status. -async fn status_with_invalid_token( - method: http::Method, - path: &str, - body: Option, -) -> Result { - let base_url: url::Url = env::var("KEYSTONE_URL") - .wrap_err("KEYSTONE_URL must be set")? - .parse()?; - let mut request = reqwest::Client::new() - .request(method, base_url.join(path)?) - .header("x-auth-token", "invalid-token"); - if let Some(body) = body { - request = request.json(&body); - } - Ok(request.send().await?.status()) -} - // --- 2xx: valid auth + allowed policy (admin) -------------------------- #[tokio::test] @@ -292,7 +204,7 @@ async fn test_group_delete_success_admin() -> Result<()> { async fn test_group_create_forbidden_for_project_scoped_manager() -> Result<()> { let admin = admin_session().await?; let domain = fresh_domain(&admin).await?; - let manager = ProjectScopedManager::provision(&admin, &domain.id).await?; + let manager = ProjectScopedUser::provision(&admin, &domain.id, "manager").await?; assert_forbidden( create_group(&manager.session, group_create(&domain.id)?).await, @@ -309,7 +221,7 @@ async fn test_group_show_forbidden_for_project_scoped_manager() -> Result<()> { let admin = admin_session().await?; let domain = fresh_domain(&admin).await?; let group = create_group(&admin, group_create(&domain.id)?).await?; - let manager = ProjectScopedManager::provision(&admin, &domain.id).await?; + let manager = ProjectScopedUser::provision(&admin, &domain.id, "manager").await?; assert_forbidden( get_group(&manager.session, &group.id).await, @@ -326,7 +238,7 @@ async fn test_group_show_forbidden_for_project_scoped_manager() -> Result<()> { async fn test_group_list_forbidden_for_project_scoped_manager() -> Result<()> { let admin = admin_session().await?; let domain = fresh_domain(&admin).await?; - let manager = ProjectScopedManager::provision(&admin, &domain.id).await?; + let manager = ProjectScopedUser::provision(&admin, &domain.id, "manager").await?; assert_forbidden( list_groups(&manager.session, GroupListRequest::default()).await, @@ -343,7 +255,7 @@ async fn test_group_update_forbidden_for_project_scoped_manager() -> Result<()> let admin = admin_session().await?; let domain = fresh_domain(&admin).await?; let group = create_group(&admin, group_create(&domain.id)?).await?; - let manager = ProjectScopedManager::provision(&admin, &domain.id).await?; + let manager = ProjectScopedUser::provision(&admin, &domain.id, "manager").await?; assert_forbidden( update_group( @@ -366,7 +278,7 @@ async fn test_group_delete_forbidden_for_project_scoped_manager() -> Result<()> let admin = admin_session().await?; let domain = fresh_domain(&admin).await?; let group = create_group(&admin, group_create(&domain.id)?).await?; - let manager = ProjectScopedManager::provision(&admin, &domain.id).await?; + let manager = ProjectScopedUser::provision(&admin, &domain.id, "manager").await?; assert_forbidden( delete_group(&manager.session, &group.id).await, @@ -384,47 +296,59 @@ async fn test_group_delete_forbidden_for_project_scoped_manager() -> Result<()> #[tokio::test] async fn test_group_create_unauthorized() -> Result<()> { - let status = status_with_invalid_token( + let rsp = raw_request( http::Method::POST, "v3/groups", + Some("invalid-token"), Some(serde_json::json!({"group": {"name": "x", "domain_id": "default"}})), ) .await?; - assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); Ok(()) } #[tokio::test] async fn test_group_show_unauthorized() -> Result<()> { - let status = - status_with_invalid_token(http::Method::GET, "v3/groups/some-group-id", None).await?; - assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + let rsp = raw_request( + http::Method::GET, + "v3/groups/some-group-id", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); Ok(()) } #[tokio::test] async fn test_group_list_unauthorized() -> Result<()> { - let status = status_with_invalid_token(http::Method::GET, "v3/groups", None).await?; - assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + let rsp = raw_request(http::Method::GET, "v3/groups", Some("invalid-token"), None).await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); Ok(()) } #[tokio::test] async fn test_group_update_unauthorized() -> Result<()> { - let status = status_with_invalid_token( + let rsp = raw_request( http::Method::PATCH, "v3/groups/some-group-id", + Some("invalid-token"), Some(serde_json::json!({"group": {"name": "x"}})), ) .await?; - assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); Ok(()) } #[tokio::test] async fn test_group_delete_unauthorized() -> Result<()> { - let status = - status_with_invalid_token(http::Method::DELETE, "v3/groups/some-group-id", None).await?; - assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED); + let rsp = raw_request( + http::Method::DELETE, + "v3/groups/some-group-id", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); Ok(()) } From 0517f9e51261a29cb70fd3730a5d4ff1d7dc129a Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 23 Jul 2026 18:20:30 -0400 Subject: [PATCH 2/9] test(api): Add EC2 credential and signing helpers Extend crud_endpoint! with an optional parent collection and unwrapped create bodies so the legacy OS-EC2 sub-resource is generated rather than hand-written. Add unguarded create and delete flavors for resources whose teardown session differs from the creating one. Add an independent AWS SigV2 HMAC-SHA256 signer with a published golden-vector unit test. Canonicalization sorts strictly by raw parameter name: the value must never act as a tiebreaker, and encoding before sorting would reorder names whose first differing byte is percent-escaped. A unit test pins that ordering. Enable ec2credential in the live-test and Skaffold Rust configurations. Keep the upstream startup timeout and failure diagnostics. Signed-off-by: ShJ-code --- Cargo.lock | 2 + tests/api/Cargo.toml | 2 + tests/api/src/auth.rs | 1 + tests/api/src/auth/ec2.rs | 203 +++++++++++ tests/api/src/credential.rs | 2 + tests/api/src/credential/ec2.rs | 93 +++++ tests/api/src/macros.rs | 342 +++++++++++++++++- .../skaffold/statefulset-dev-patch.yaml | 2 +- tools/start-api.sh | 2 +- 9 files changed, 629 insertions(+), 20 deletions(-) create mode 100644 tests/api/src/auth/ec2.rs create mode 100644 tests/api/src/credential/ec2.rs diff --git a/Cargo.lock b/Cargo.lock index 993edde0f..de231f0a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8957,6 +8957,7 @@ dependencies = [ "chrono", "derive_builder", "eyre", + "hmac 0.13.0", "http", "openstack-keystone-api-types", "openstack-keystone-distributed-storage", @@ -8966,6 +8967,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "sha2 0.11.0", "spiffe", "spiffe-rustls", "tokio", diff --git a/tests/api/Cargo.toml b/tests/api/Cargo.toml index 03b9aa2a0..726615488 100644 --- a/tests/api/Cargo.toml +++ b/tests/api/Cargo.toml @@ -20,6 +20,7 @@ bytes.workspace = true chrono = { workspace = true, features = ["serde", "now"] } eyre.workspace = true derive_builder.workspace = true +hmac.workspace = true http.workspace = true openstack-keystone-api-types = { workspace = true, features = ["builder"] } openstack-keystone-distributed-storage = { workspace = true } @@ -29,6 +30,7 @@ secrecy = { workspace = true, features = ["serde"] } serde.workspace = true serde_json.workspace = true serde_urlencoded.workspace = true +sha2.workspace = true spiffe = { workspace = true, features = ["x509-source"] } spiffe-rustls.workspace = true tracing.workspace = true diff --git a/tests/api/src/auth.rs b/tests/api/src/auth.rs index 0a5829846..fbfb0408c 100644 --- a/tests/api/src/auth.rs +++ b/tests/api/src/auth.rs @@ -13,5 +13,6 @@ // SPDX-License-Identifier: Apache-2.0 pub mod auth_plugin; +pub mod ec2; pub mod project; pub mod token; diff --git a/tests/api/src/auth/ec2.rs b/tests/api/src/auth/ec2.rs new file mode 100644 index 000000000..286edd7ef --- /dev/null +++ b/tests/api/src/auth/ec2.rs @@ -0,0 +1,203 @@ +// 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 +//! EC2 (AWS Signature Version 2) signing and `POST /v3/ec2tokens` helpers. +//! +//! The signer here is a deliberately **independent** implementation of the +//! AWS SigV2 query-signing algorithm, written from the public AWS +//! specification and validated against the published AWS documentation +//! golden vector (see the unit test below) — it does NOT reuse the +//! server's `openstack_keystone_core::credential::ec2_signature`, so a +//! canonicalization defect on the server cannot be masked by sharing the +//! same code on both sides of the wire. + +use std::collections::HashMap; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use eyre::Result; +use hmac::{Hmac, KeyInit, Mac}; +use reqwest::{Response, StatusCode}; +use sha2::Sha256; + +use crate::common::raw_request; + +/// Percent-encode per RFC 3986 with the AWS unreserved set +/// (`A-Za-z0-9`, `-`, `_`, `.`, `~`), uppercase hex digits. +fn aws_uri_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for byte in input.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// Order the parameters by raw name and join them percent-encoded. +fn canonical_query(params: &HashMap) -> String { + let mut pairs: Vec<(&String, &String)> = params.iter().collect(); + pairs.sort_by_key(|(key, _)| *key); + pairs + .into_iter() + .map(|(key, value)| format!("{}={}", aws_uri_encode(key), aws_uri_encode(value))) + .collect::>() + .join("&") +} + +/// AWS Signature Version 2 with `SignatureMethod=HmacSHA256`: +/// +/// ```text +/// StringToSign = VERB + "\n" + Host + "\n" + Path + "\n" + CanonicalQuery +/// CanonicalQuery = join("&", "=" ordered by raw k byte order) +/// Signature = base64(HMAC-SHA256(secret, StringToSign)) +/// ``` +/// +/// The spec orders by parameter *name* only, so the value must never act as +/// a tiebreaker and the encoded form must never affect the ordering. +pub fn sign_v2_hmac_sha256( + secret: &str, + verb: &str, + host: &str, + path: &str, + params: &HashMap, +) -> Result { + let string_to_sign = format!("{verb}\n{host}\n{path}\n{}", canonical_query(params)); + + let mut mac = as KeyInit>::new_from_slice(secret.as_bytes()) + .map_err(|e| eyre::eyre!("HMAC key error: {e}"))?; + mac.update(string_to_sign.as_bytes()); + Ok(BASE64.encode(mac.finalize().into_bytes())) +} + +/// Build a signed `POST /v3/ec2tokens` request body for the given EC2 +/// credential. `timestamp` defaults to now (RFC 3339); pass an old value +/// to exercise the stale-timestamp rejection. `signature_override` +/// replaces the correctly computed signature to exercise the +/// bad-signature rejection. +pub fn ec2_token_request_body( + access: &str, + secret: &str, + timestamp: Option, + signature_override: Option<&str>, +) -> Result { + let host = "identity.example.com"; + let verb = "GET"; + let path = "/"; + let mut params = HashMap::from([ + ("SignatureVersion".to_string(), "2".to_string()), + ("SignatureMethod".to_string(), "HmacSHA256".to_string()), + ( + "Timestamp".to_string(), + timestamp.unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + ), + ]); + params.insert("AWSAccessKeyId".to_string(), access.to_string()); + + let signature = match signature_override { + Some(sig) => sig.to_string(), + None => sign_v2_hmac_sha256(secret, verb, host, path, ¶ms)?, + }; + + Ok(serde_json::json!({ + "credentials": { + "access": access, + "signature": signature, + "host": host, + "verb": verb, + "path": path, + "params": params, + } + })) +} + +/// POST the signed body to `/v3/ec2tokens` with the given caller token +/// (`None` = unauthenticated). Returns the raw response. +pub async fn post_ec2_token( + caller_token: Option<&str>, + body: serde_json::Value, +) -> Result { + raw_request(http::Method::POST, "v3/ec2tokens", caller_token, Some(body)).await +} + +/// POST to `/v3/ec2tokens` and extract `(status, subject_token, body)`. +pub async fn post_ec2_token_extract( + caller_token: Option<&str>, + body: serde_json::Value, +) -> Result<(StatusCode, Option, serde_json::Value)> { + let rsp = post_ec2_token(caller_token, body).await?; + let status = rsp.status(); + let subject_token = rsp + .headers() + .get("X-Subject-Token") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = rsp.json().await.unwrap_or(serde_json::Value::Null); + Ok((status, subject_token, body)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The published AWS "Signature Version 2 signing process" golden + /// vector (the `DescribeJobFlows` example from the AWS General + /// Reference): a known secret, host, and parameter set with the + /// documented expected signature. An independent SigV2 implementation + /// must reproduce it exactly. + #[test] + fn aws_documentation_golden_vector() -> Result<()> { + let params = HashMap::from([ + ( + "AWSAccessKeyId".to_string(), + "AKIAIOSFODNN7EXAMPLE".to_string(), + ), + ("Action".to_string(), "DescribeJobFlows".to_string()), + ("SignatureMethod".to_string(), "HmacSHA256".to_string()), + ("SignatureVersion".to_string(), "2".to_string()), + ("Timestamp".to_string(), "2011-10-03T15:19:30".to_string()), + ("Version".to_string(), "2009-03-31".to_string()), + ]); + let signature = sign_v2_hmac_sha256( + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "GET", + "elasticmapreduce.amazonaws.com", + "/", + ¶ms, + )?; + assert_eq!(signature, "i91nKc4PWAt0JJIdXwz9HxZCJDdiy6cf/Mj6vPxyYIs="); + Ok(()) + } + + #[test] + fn uri_encoding_matches_aws_rules() { + assert_eq!( + aws_uri_encode("2011-10-03T15:19:30"), + "2011-10-03T15%3A19%3A30" + ); + assert_eq!(aws_uri_encode("a b/c~d_e.f-g"), "a%20b%2Fc~d_e.f-g"); + } + + #[test] + fn canonical_query_orders_by_raw_key() { + let params = HashMap::from([ + ("a/".to_string(), "1".to_string()), + ("a-".to_string(), "2".to_string()), + ]); + // `-` (0x2D) sorts after `%` (0x25), so encoding before sorting would + // swap these two parameters and sign a different string. + assert_eq!(canonical_query(¶ms), "a-=2&a%2F=1"); + } +} diff --git a/tests/api/src/credential.rs b/tests/api/src/credential.rs index 6fd755394..1e54c4a69 100644 --- a/tests/api/src/credential.rs +++ b/tests/api/src/credential.rs @@ -13,6 +13,8 @@ // SPDX-License-Identifier: Apache-2.0 //! Credential REST endpoint helpers and test infrastructure (ADR 0019). +pub mod ec2; + use std::borrow::Cow; use std::sync::Arc; diff --git a/tests/api/src/credential/ec2.rs b/tests/api/src/credential/ec2.rs new file mode 100644 index 000000000..8cd5a6ca0 --- /dev/null +++ b/tests/api/src/credential/ec2.rs @@ -0,0 +1,93 @@ +// 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 +//! OS-EC2 credential helpers (`/v3/users/{user_id}/credentials/OS-EC2`), +//! generated with [`crate::macros::crud_endpoint`]. +//! +//! This legacy API is a user sub-resource whose create body is not wrapped +//! in a resource key, so the blocks below set `parent` and omit `body_key`. +//! The authorization tests create a credential with one session and delete +//! it with another, so the unguarded flavors are used rather than the +//! [`crate::guard`]-based ones. + +use std::sync::Arc; + +use eyre::Result; + +use openstack_keystone_api_types::v3::os_ec2_credential::*; +use openstack_sdk::AsyncOpenStack; + +use crate::macros::crud_endpoint; + +crud_endpoint! { + create_unguarded { + request = Ec2CredentialCreateApiRequest, + func = create_ec2_credential_with_body, + parent = ("users", user_id), + path = "credentials/OS-EC2", + create_type = Ec2CredentialCreateRequest, + model = Ec2Credential, + response_key = "credential", + service = Identity, + api_version = (3, 0), + } + show { + request = Ec2CredentialShowApiRequest, + func = get_ec2_credential, + parent = ("users", user_id), + path = "credentials/OS-EC2", + model = Ec2Credential, + response_key = "credential", + service = Identity, + api_version = (3, 0), + } + list { + request = Ec2CredentialListRequest, + func = list_ec2_credentials, + parent = ("users", user_id), + path = "credentials/OS-EC2", + model = Ec2Credential, + response_key = "credentials", + service = Identity, + api_version = (3, 0), + query = [], + } + delete_fn { + request = Ec2CredentialDeleteApiRequest, + func = delete_ec2_credential, + parent = ("users", user_id), + path = "credentials/OS-EC2", + service = Identity, + api_version = (3, 0), + } +} + +/// Create an EC2 credential for `user_id` bound to `project_id`, letting the +/// server generate the access/secret pair (ADR 0019 §2, "Automatic +/// Creation"). +pub async fn create_ec2_credential( + tc: &Arc, + user_id: &str, + project_id: &str, +) -> Result { + create_ec2_credential_with_body( + tc, + user_id, + Ec2CredentialCreateRequest { + project_id: project_id.to_string(), + access: None, + secret: None, + }, + ) + .await +} diff --git a/tests/api/src/macros.rs b/tests/api/src/macros.rs index f107fb1c4..e871f6771 100644 --- a/tests/api/src/macros.rs +++ b/tests/api/src/macros.rs @@ -23,8 +23,19 @@ //! out at the call site (no identifier concatenation), so compile errors //! point at readable names. Operations are selectable — a resource without //! an update handler simply omits the `update` block. Endpoints that do not -//! fit these shapes (sub-resources, grants, borrowed fields, non-JSON -//! bodies) should keep hand-written `RestEndpoint` impls. +//! fit these shapes (grants, borrowed fields, non-JSON bodies) should keep +//! hand-written `RestEndpoint` impls. +//! +//! Two optional fields cover the legacy OS-EC2 shape: +//! +//! - `parent = ("users", user_id)` prefixes the endpoint with a parent +//! collection and its ID, for sub-resources such as +//! `users/{user_id}/credentials/OS-EC2`. The request struct gains a +//! `user_id` field and every generated wrapper takes it as the first +//! argument after `tc`. +//! - Omitting `body_key` from a `create` block serializes `create_type` +//! unwrapped at the request root, for legacy bodies that are not nested +//! under a resource key. //! //! Canonical field order per operation (all fields required, see arms //! below): @@ -90,25 +101,63 @@ //! have an `id: String` field), and `pub async fn (tc, id) -> //! Result<()>`. Use `delete_impl` instead of `delete` when only the //! `DeletableResource` impl is wanted without a public delete function. +//! - `create_unguarded`: as `create`, but the wrapper returns the created +//! `model` directly instead of a guard. Use when the test deletes the +//! resource with a different session than the one that created it, so an +//! [`AsyncResourceGuard`](crate::guard::AsyncResourceGuard) — which always +//! deletes with the creating session — does not fit. +//! - `delete_fn`: as `delete`, but without the `DeletableResource` impl, for +//! models that are never guarded (e.g. sub-resource models with no `id` +//! field). macro_rules! crud_endpoint { // Entry: one or more operation blocks. ($($op:ident { $($body:tt)* })+) => { $(crud_endpoint!(@ $op { $($body)* });)+ }; - (@ create { + // Internal: the collection path of an operation, optionally nested + // under `parent_path/{parent_id}`. + (@ collection_path $me:ident, $path:literal $(, $parent_path:literal, $parent:ident)?) => {{ + let mut path = ::std::string::String::new(); + $( + path.push_str($parent_path); + path.push('/'); + path.push_str(&$me.$parent); + path.push('/'); + )? + path.push_str($path); + path + }}; + + // Internal: wrapped (`{"group": {..}}`) vs. unwrapped request body. + (@ push_body $params:ident, $body:ident, $body_key:literal) => { + $params.push($body_key, $body); + }; + (@ push_body $params:ident, $body:ident) => { + // Legacy bodies that are not nested under a resource key: splice the + // serialized object's own fields into the request root. + if let ::serde_json::Value::Object(fields) = $body { + for (key, value) in fields { + $params.push(key, value); + } + } + }; + + // Internal: request struct and `RestEndpoint` impl shared by `create` + // and `create_unguarded`. + (@ create_request { request = $request:ident, - func = $func:ident, + $(parent = ($parent_path:literal, $parent:ident),)? path = $path:literal, - body_key = $body_key:literal, + $(body_key = $body_key:literal,)? create_type = $create_type:ty, - model = $model:ty, response_key = $response_key:literal, service = $service:ident, api_version = ($major:literal, $minor:literal) $(,)? }) => { #[derive(Clone, Debug)] struct $request { + $($parent: String,)? body: $create_type, } @@ -118,7 +167,7 @@ macro_rules! crud_endpoint { } fn endpoint(&self) -> ::std::borrow::Cow<'static, str> { - $path.into() + crud_endpoint!(@ collection_path self, $path $(, $parent_path, $parent)?).into() } fn body( @@ -129,7 +178,8 @@ macro_rules! crud_endpoint { > { let mut params = ::openstack_sdk::api::rest_endpoint_prelude::JsonBodyParams::default(); - params.push($body_key, ::serde_json::to_value(&self.body)?); + let body = ::serde_json::to_value(&self.body)?; + crud_endpoint!(@ push_body params, body $(, $body_key)?); params.into_body() } @@ -151,22 +201,95 @@ macro_rules! crud_endpoint { )) } } + }; + + (@ create { + request = $request:ident, + func = $func:ident, + $(parent = ($parent_path:literal, $parent:ident),)? + path = $path:literal, + $(body_key = $body_key:literal,)? + create_type = $create_type:ty, + model = $model:ty, + response_key = $response_key:literal, + service = $service:ident, + api_version = ($major:literal, $minor:literal) $(,)? + }) => { + crud_endpoint!(@ create_request { + request = $request, + $(parent = ($parent_path, $parent),)? + path = $path, + $(body_key = $body_key,)? + create_type = $create_type, + response_key = $response_key, + service = $service, + api_version = ($major, $minor), + }); /// Create the resource, returning a guard that must be explicitly /// deleted with `.delete().await?` (see [`crate::guard`]). pub async fn $func( tc: &::std::sync::Arc<::openstack_sdk::AsyncOpenStack>, + $($parent: &str,)? body: $create_type, ) -> ::eyre::Result<$crate::guard::AsyncResourceGuard<$model>> { use ::openstack_sdk::api::QueryAsync; - let obj: $model = $request { body }.query_async(tc.as_ref()).await?; + let obj: $model = $request { + $($parent: $parent.to_string(),)? + body, + } + .query_async(tc.as_ref()) + .await?; Ok($crate::guard::AsyncResourceGuard::new(obj, tc.clone())) } }; + // Create without a guard: the caller owns teardown explicitly. + (@ create_unguarded { + request = $request:ident, + func = $func:ident, + $(parent = ($parent_path:literal, $parent:ident),)? + path = $path:literal, + $(body_key = $body_key:literal,)? + create_type = $create_type:ty, + model = $model:ty, + response_key = $response_key:literal, + service = $service:ident, + api_version = ($major:literal, $minor:literal) $(,)? + }) => { + crud_endpoint!(@ create_request { + request = $request, + $(parent = ($parent_path, $parent),)? + path = $path, + $(body_key = $body_key,)? + create_type = $create_type, + response_key = $response_key, + service = $service, + api_version = ($major, $minor), + }); + + /// Create the resource and return it directly. The caller is + /// responsible for deleting it (see [`crate::guard`] for the + /// guarded alternative). + pub async fn $func( + tc: &::std::sync::Arc<::openstack_sdk::AsyncOpenStack>, + $($parent: &str,)? + body: $create_type, + ) -> ::eyre::Result<$model> { + use ::openstack_sdk::api::QueryAsync; + Ok($request { + $($parent: $parent.to_string(),)? + body, + } + .query_async(tc.as_ref()) + .await?) + } + }; + (@ show { request = $request:ident, func = $func:ident, + $(parent = ($parent_path:literal, $parent:ident),)? path = $path:literal, model = $model:ty, response_key = $response_key:literal, @@ -175,6 +298,7 @@ macro_rules! crud_endpoint { }) => { #[derive(Clone, Debug)] struct $request { + $($parent: String,)? id: String, } @@ -184,7 +308,12 @@ macro_rules! crud_endpoint { } fn endpoint(&self) -> ::std::borrow::Cow<'static, str> { - format!("{}/{}", $path, self.id).into() + format!( + "{}/{}", + crud_endpoint!(@ collection_path self, $path $(, $parent_path, $parent)?), + self.id, + ) + .into() } fn service_type( @@ -209,10 +338,16 @@ macro_rules! crud_endpoint { /// Show a single resource by ID. pub async fn $func( tc: &::std::sync::Arc<::openstack_sdk::AsyncOpenStack>, + $($parent: &str,)? id: impl Into, ) -> ::eyre::Result<$model> { use ::openstack_sdk::api::QueryAsync; - Ok($request { id: id.into() }.query_async(tc.as_ref()).await?) + Ok($request { + $($parent: $parent.to_string(),)? + id: id.into(), + } + .query_async(tc.as_ref()) + .await?) } }; @@ -292,6 +427,7 @@ macro_rules! crud_endpoint { (@ list { request = $request:ident, func = $func:ident, + $(parent = ($parent_path:literal, $parent:ident),)? path = $path:literal, model = $model:ty, response_key = $response_key:literal, @@ -299,9 +435,11 @@ macro_rules! crud_endpoint { api_version = ($major:literal, $minor:literal), query = [$($query_field:ident),* $(,)?] $(,)? }) => { - /// List request query parameters. + /// List request query parameters. A `parent` ID, when the resource + /// has one, is set by the wrapper function rather than the caller. #[derive(Clone, Debug, Default)] pub struct $request { + $($parent: String,)? $(pub $query_field: Option,)* } @@ -311,12 +449,15 @@ macro_rules! crud_endpoint { } fn endpoint(&self) -> ::std::borrow::Cow<'static, str> { - $path.into() + crud_endpoint!(@ collection_path self, $path $(, $parent_path, $parent)?).into() } fn parameters( &self, ) -> ::openstack_sdk::api::rest_endpoint_prelude::QueryParams<'_> { + // `query = []` (sub-resource collections) leaves `params` + // untouched. + #[allow(unused_mut)] let mut params = ::openstack_sdk::api::rest_endpoint_prelude::QueryParams::default(); $(params.push_opt(stringify!($query_field), self.$query_field.as_ref());)* @@ -345,9 +486,13 @@ macro_rules! crud_endpoint { /// List resources matching the query parameters. pub async fn $func( tc: &::std::sync::Arc<::openstack_sdk::AsyncOpenStack>, + $($parent: &str,)? params: $request, ) -> ::eyre::Result> { use ::openstack_sdk::api::QueryAsync; + #[allow(unused_mut)] + let mut params = params; + $(params.$parent = $parent.to_string();)? Ok(params.query_async(tc.as_ref()).await?) } }; @@ -383,17 +528,52 @@ macro_rules! crud_endpoint { } }; - // Delete without a public wrapper: only the request struct and the - // `DeletableResource` impl used by `AsyncResourceGuard`. - (@ delete_impl { + // Delete with a public wrapper function but without the + // `DeletableResource` impl, for models that are never guarded. + (@ delete_fn { request = $request:ident, + func = $func:ident, + $(parent = ($parent_path:literal, $parent:ident),)? + path = $path:literal, + service = $service:ident, + api_version = ($major:literal, $minor:literal) $(,)? + }) => { + crud_endpoint!(@ delete_request { + request = $request, + $(parent = ($parent_path, $parent),)? + path = $path, + service = $service, + api_version = ($major, $minor), + }); + + /// Delete the resource identified by `id`. + pub async fn $func( + tc: &::std::sync::Arc<::openstack_sdk::AsyncOpenStack>, + $($parent: &str,)? + id: impl Into, + ) -> ::eyre::Result<()> { + use ::openstack_sdk::api::QueryAsync; + Ok(::openstack_sdk::api::ignore($request { + $($parent: $parent.to_string(),)? + id: id.into(), + }) + .query_async(tc.as_ref()) + .await?) + } + }; + + // Internal: request struct and `RestEndpoint` impl shared by every + // delete flavor. + (@ delete_request { + request = $request:ident, + $(parent = ($parent_path:literal, $parent:ident),)? path = $path:literal, - model = $model:ty, service = $service:ident, api_version = ($major:literal, $minor:literal) $(,)? }) => { #[derive(Clone, Debug)] struct $request { + $($parent: String,)? id: String, } @@ -403,7 +583,12 @@ macro_rules! crud_endpoint { } fn endpoint(&self) -> ::std::borrow::Cow<'static, str> { - format!("{}/{}", $path, self.id).into() + format!( + "{}/{}", + crud_endpoint!(@ collection_path self, $path $(, $parent_path, $parent)?), + self.id, + ) + .into() } fn service_type( @@ -420,6 +605,23 @@ macro_rules! crud_endpoint { )) } } + }; + + // Delete without a public wrapper: only the request struct and the + // `DeletableResource` impl used by `AsyncResourceGuard`. + (@ delete_impl { + request = $request:ident, + path = $path:literal, + model = $model:ty, + service = $service:ident, + api_version = ($major:literal, $minor:literal) $(,)? + }) => { + crud_endpoint!(@ delete_request { + request = $request, + path = $path, + service = $service, + api_version = ($major, $minor), + }); #[async_trait::async_trait] impl $crate::guard::DeletableResource for $model { @@ -620,6 +822,110 @@ mod tests { } } + /// Sub-resource combination with an unwrapped create body and no + /// `DeletableResource` impl (the legacy OS-EC2 shape). + mod sub_resource { + use super::*; + + #[derive(Clone, Debug, Deserialize, Serialize)] + pub struct Bolt { + pub access: String, + } + + #[derive(Clone, Debug, Deserialize, Serialize)] + pub struct BoltCreate { + #[serde(rename = "tenant_id")] + pub project_id: String, + } + + crate::macros::crud_endpoint! { + create_unguarded { + request = BoltCreateApiRequest, + func = create_bolt, + parent = ("users", user_id), + path = "credentials/OS-EC2", + create_type = BoltCreate, + model = Bolt, + response_key = "credential", + service = Identity, + api_version = (3, 0), + } + show { + request = BoltShowApiRequest, + func = get_bolt, + parent = ("users", user_id), + path = "credentials/OS-EC2", + model = Bolt, + response_key = "credential", + service = Identity, + api_version = (3, 0), + } + list { + request = BoltListRequest, + func = list_bolts, + parent = ("users", user_id), + path = "credentials/OS-EC2", + model = Bolt, + response_key = "credentials", + service = Identity, + api_version = (3, 0), + query = [], + } + delete_fn { + request = BoltDeleteApiRequest, + func = delete_bolt, + parent = ("users", user_id), + path = "credentials/OS-EC2", + service = Identity, + api_version = (3, 0), + } + } + + #[test] + fn create_request_nests_under_parent_and_unwraps_body() { + let req = BoltCreateApiRequest { + user_id: "uid".into(), + body: BoltCreate { + project_id: "pid".into(), + }, + }; + assert_eq!(req.method(), http::Method::POST); + assert_eq!(req.endpoint(), "users/uid/credentials/OS-EC2"); + let (content_type, body) = req.body().ok().flatten().unwrap_or(("missing", Vec::new())); + assert_eq!(content_type, "application/json"); + // No resource key wrapper: the body's own fields sit at the root. + assert_eq!(String::from_utf8_lossy(&body), r#"{"tenant_id":"pid"}"#); + } + + #[test] + fn item_requests_nest_under_parent() { + let show = BoltShowApiRequest { + user_id: "uid".into(), + id: "acc".into(), + }; + assert_eq!(show.endpoint(), "users/uid/credentials/OS-EC2/acc"); + + let delete = BoltDeleteApiRequest { + user_id: "uid".into(), + id: "acc".into(), + }; + assert_eq!(delete.method(), http::Method::DELETE); + assert_eq!(delete.endpoint(), "users/uid/credentials/OS-EC2/acc"); + assert_eq!(delete.response_key(), None); + } + + #[test] + fn list_request_nests_under_parent() { + // The wrapper function sets the parent ID for the caller. + let req = BoltListRequest { + user_id: "uid".into(), + }; + assert_eq!(req.method(), http::Method::GET); + assert_eq!(req.endpoint(), "users/uid/credentials/OS-EC2"); + assert_eq!(req.response_key().as_deref(), Some("credentials")); + } + } + /// A single standalone operation must also expand. mod single_op { use super::*; diff --git a/tools/k8s/keystone/overlays/skaffold/statefulset-dev-patch.yaml b/tools/k8s/keystone/overlays/skaffold/statefulset-dev-patch.yaml index 1bb7ac6a8..38346237d 100644 --- a/tools/k8s/keystone/overlays/skaffold/statefulset-dev-patch.yaml +++ b/tools/k8s/keystone/overlays/skaffold/statefulset-dev-patch.yaml @@ -31,7 +31,7 @@ spec: PLUGIN_SHA256=$(sha256sum /usr/local/lib/reference_plugin.wasm | awk '{print $1}') cat < /etc/keystone/site.toml [auth] - methods = "password,token,openid,application_credential,x509,mapped,hacked_appcred_handler" + methods = "password,token,openid,application_credential,x509,mapped,hacked_appcred_handler,ec2credential" [distributed_storage] node_id = $NODE_ID node_cluster_addr = "https://$POD_NAME.keystone-rs-internal.default.svc.cluster.local:8300" diff --git a/tools/start-api.sh b/tools/start-api.sh index bbfc5a487..990240b1f 100755 --- a/tools/start-api.sh +++ b/tools/start-api.sh @@ -68,7 +68,7 @@ opa_policies_path = policy # into a token as a bitmask over exactly this list # (crates/token-driver-fernet/src/lib.rs), so both must be present here or # token issuance 500s with "unsupported authentication methods". -methods = password,token,openid,application_credential,x509,mapped,hacked_appcred_handler +methods = password,token,openid,application_credential,x509,mapped,hacked_appcred_handler,ec2credential [DEFAULT] use_stderr = false From c6f96ae0a2442ce50f76db75c4d44c6b68f27b1a Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 23 Jul 2026 18:20:31 -0400 Subject: [PATCH 3/9] test(api): Cover v3 EC2 credential authorization Cover all four OS-EC2 credential handlers with owner success, cross-user policy denial, and invalid-token rejection. Exercise EC2 token issuance, caller policy, signature and timestamp validation, subject-token validation, and token-from-token reauthentication through live API tests. Route the invalid-token cases through assert_unauthorized so a wrong status reports the full error chain instead of a bare status compare, and reduce asserted values to their access key: Ec2Credential's derived Debug carries the plaintext secret, which the assertion helpers would print on an unexpected success. Release the already-provisioned fixtures when a later setup step fails, so a mid-setup error leaves no orphaned users, projects or credentials behind on the live server. Signed-off-by: ShJ-code --- tests/api/tests/api_v3/auth.rs | 1 + tests/api/tests/api_v3/auth/ec2tokens.rs | 294 +++++++++++++++++++++++ tests/api/tests/api_v3/credential.rs | 1 + tests/api/tests/api_v3/credential/ec2.rs | 271 +++++++++++++++++++++ 4 files changed, 567 insertions(+) create mode 100644 tests/api/tests/api_v3/auth/ec2tokens.rs create mode 100644 tests/api/tests/api_v3/credential/ec2.rs diff --git a/tests/api/tests/api_v3/auth.rs b/tests/api/tests/api_v3/auth.rs index 3b1dfae0d..ce0f5541d 100644 --- a/tests/api/tests/api_v3/auth.rs +++ b/tests/api/tests/api_v3/auth.rs @@ -11,5 +11,6 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +mod ec2tokens; mod project; mod token; diff --git a/tests/api/tests/api_v3/auth/ec2tokens.rs b/tests/api/tests/api_v3/auth/ec2tokens.rs new file mode 100644 index 000000000..e8b974268 --- /dev/null +++ b/tests/api/tests/api_v3/auth/ec2tokens.rs @@ -0,0 +1,294 @@ +// 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 +//! `POST /v3/ec2tokens` authorization and signature matrix (issue #993). +//! +//! Caller gate (`policy/ec2tokens/validate.rego`, CVE-2025-65073): the +//! endpoint itself requires an **authenticated** caller holding the +//! `admin` or `service` role — the signed EC2 request only authenticates +//! the credential owner, not the caller. +//! +//! | case | test | +//! |------|------| +//! | admin caller + valid signature → token | `test_ec2_token_issue_success_admin_caller` | +//! | unauthenticated caller | `test_ec2_token_unauthenticated` | +//! | member caller (policy denial) | `test_ec2_token_forbidden_member_caller` | +//! | wrong signature | `test_ec2_token_bad_signature_rejected` | +//! | stale timestamp | `test_ec2_token_stale_timestamp_rejected` | +//! | issued token validates at /v3/auth/tokens | `test_ec2_token_validates_at_auth_tokens` | +//! | token-from-token reauth still allowed | `test_ec2_token_reauth_allowed` | +//! +//! The signature is produced by `test_api::auth::ec2`'s independent SigV2 +//! implementation (validated against the published AWS golden vector), so +//! these tests exercise the server's canonicalization rather than +//! mirroring it. + +use std::sync::Arc; + +use eyre::{OptionExt, Result}; +use reqwest::StatusCode; +use secrecy::ExposeSecret; + +use openstack_keystone_api_types::scope::{DomainBuilder, Scope, ScopeProjectBuilder}; +use openstack_keystone_api_types::v3::auth::token::TokenResponse; +use openstack_keystone_api_types::v3::os_ec2_credential::Ec2Credential; +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; + +use test_api::asserts::{assert_forbidden, assert_unauthorized}; +use test_api::auth::ec2::{ec2_token_request_body, post_ec2_token, post_ec2_token_extract}; +use test_api::common::TestClient; +use test_api::credential::ec2::{create_ec2_credential, delete_ec2_credential}; +use test_api::fixtures::{FIXTURE_PASSWORD, ProjectScopedUser, warn_on_cleanup_failure}; + +async fn admin_session() -> Result> { + Ok(Arc::new( + AsyncOpenStack::new(&CloudConfig::from_env()?).await?, + )) +} + +/// An admin caller token for the `x-auth-token` header. +async fn admin_token() -> Result { + let mut tc = TestClient::default()?; + tc.auth_admin().await?; + Ok(tc + .token + .as_ref() + .ok_or_eyre("admin token must be present")? + .expose_secret() + .to_string()) +} + +/// Member fixture + its EC2 credential (created by the member itself). +async fn member_with_credential( + admin: &Arc, +) -> Result<(ProjectScopedUser, Ec2Credential)> { + let member = ProjectScopedUser::provision(admin, "default", "member").await?; + match create_ec2_credential(&member.session, &member.user.id, &member.project.id).await { + Ok(cred) => Ok((member, cred)), + Err(error) => { + warn_on_cleanup_failure("member fixture", member.cleanup().await); + Err(error) + } + } +} + +async fn cleanup( + admin: &Arc, + member: ProjectScopedUser, + cred: &Ec2Credential, +) -> Result<()> { + delete_ec2_credential(admin, &member.user.id, &cred.access).await?; + member.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_token_issue_success_admin_caller() -> Result<()> { + let admin = admin_session().await?; + let (member, cred) = member_with_credential(&admin).await?; + + let body = ec2_token_request_body(&cred.access, &cred.secret, None, None)?; + let (status, subject_token, response) = + post_ec2_token_extract(Some(&admin_token().await?), body).await?; + + assert_eq!(status, StatusCode::OK, "response: {response}"); + assert!( + subject_token.is_some_and(|token| !token.is_empty()), + "X-Subject-Token must carry the issued token" + ); + assert_eq!( + response["token"]["project"]["id"], cred.project_id, + "token must be scoped to the credential's project" + ); + assert_eq!( + response["token"]["user"]["id"], member.user.id, + "token must belong to the credential owner" + ); + + cleanup(&admin, member, &cred).await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_token_unauthenticated() -> Result<()> { + let body = ec2_token_request_body("AKIA-nonexistent", "irrelevant-secret", None, None)?; + let response = post_ec2_token(None, body).await?; + assert_unauthorized( + response.error_for_status(), + "unauthenticated callers must be rejected before signature handling", + ); + Ok(()) +} + +#[tokio::test] +async fn test_ec2_token_forbidden_member_caller() -> Result<()> { + let admin = admin_session().await?; + let (member, cred) = member_with_credential(&admin).await?; + + // Even with a perfectly valid signature over their own credential, a + // plain member caller is not `admin`/`service` and must be denied. + let mut member_tc = TestClient::default()?; + member_tc + .auth_password( + test_api::common::get_password_auth(&member.user.name, FIXTURE_PASSWORD, "default")?, + Some(Scope::Project( + ScopeProjectBuilder::default() + .id(member.project.id.clone()) + .domain(DomainBuilder::default().id("default").build()?) + .build()?, + )), + ) + .await?; + let member_token = member_tc + .token + .as_ref() + .ok_or_eyre("member token must be present")? + .expose_secret() + .to_string(); + + let body = ec2_token_request_body(&cred.access, &cred.secret, None, None)?; + let response = post_ec2_token(Some(&member_token), body).await?; + assert_forbidden( + response.error_for_status(), + "member callers must be denied by policy", + ); + + cleanup(&admin, member, &cred).await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_token_bad_signature_rejected() -> Result<()> { + let admin = admin_session().await?; + let (member, cred) = member_with_credential(&admin).await?; + + let body = ec2_token_request_body(&cred.access, &cred.secret, None, Some("bogus-signature"))?; + let (status, subject_token, _) = + post_ec2_token_extract(Some(&admin_token().await?), body).await?; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "a wrong signature must not authenticate" + ); + assert!(subject_token.is_none(), "no token may be issued"); + + cleanup(&admin, member, &cred).await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_token_stale_timestamp_rejected() -> Result<()> { + let admin = admin_session().await?; + let (member, cred) = member_with_credential(&admin).await?; + + // Correctly signed, but over a timestamp far outside the auth TTL — + // a replayed capture must be rejected. + let body = ec2_token_request_body( + &cred.access, + &cred.secret, + Some("2011-10-03T15:19:30Z".to_string()), + None, + )?; + let (status, subject_token, _) = + post_ec2_token_extract(Some(&admin_token().await?), body).await?; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "a stale signed request must not authenticate" + ); + assert!(subject_token.is_none(), "no token may be issued"); + + cleanup(&admin, member, &cred).await?; + Ok(()) +} + +/// Obtain a real EC2-issued token for the restriction tests below. +async fn issue_ec2_token( + admin: &Arc, +) -> Result<(ProjectScopedUser, Ec2Credential, String)> { + let (member, cred) = member_with_credential(admin).await?; + let token_result = async { + let body = ec2_token_request_body(&cred.access, &cred.secret, None, None)?; + let (status, subject_token, response) = + post_ec2_token_extract(Some(&admin_token().await?), body).await?; + eyre::ensure!( + status == StatusCode::OK, + "EC2 token issuance failed with {status}: {response}" + ); + subject_token.ok_or_eyre("token must be issued") + } + .await; + + match token_result { + Ok(token) => Ok((member, cred, token)), + Err(error) => { + warn_on_cleanup_failure("EC2 fixture", cleanup(admin, member, &cred).await); + Err(error) + } + } +} + +#[tokio::test] +async fn test_ec2_token_validates_at_auth_tokens() -> Result<()> { + let admin = admin_session().await?; + let (member, cred, token) = issue_ec2_token(&admin).await?; + + // The one place the EC2 token remains a valid *subject*: token + // validation by an authorized caller. + let mut tc = TestClient::default()?; + tc.auth_admin().await?; + let rsp = test_api::auth::token::check_token(&tc, &token.clone().into()).await?; + assert_eq!( + rsp.status(), + StatusCode::OK, + "an EC2-issued token must still validate at GET /v3/auth/tokens" + ); + let response: TokenResponse = rsp.json().await?; + assert!( + response + .token + .methods + .iter() + .any(|method| method == "ec2credential"), + "token validation must expose the immutable EC2 method marker" + ); + + cleanup(&admin, member, &cred).await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_token_reauth_allowed() -> Result<()> { + let admin = admin_session().await?; + let (member, cred, token) = issue_ec2_token(&admin).await?; + + // #1071 explicitly keeps token-from-token reauth working. + let mut tc = TestClient::default()?; + tc.auth_token( + &token, + Some(Scope::Project( + ScopeProjectBuilder::default() + .id(member.project.id.clone()) + .domain(DomainBuilder::default().id("default").build()?) + .build()?, + )), + ) + .await?; + assert!( + tc.token.is_some(), + "token-from-token reauth with an EC2-issued token must succeed" + ); + + cleanup(&admin, member, &cred).await?; + Ok(()) +} diff --git a/tests/api/tests/api_v3/credential.rs b/tests/api/tests/api_v3/credential.rs index c4b957a8d..8c19f4e3d 100644 --- a/tests/api/tests/api_v3/credential.rs +++ b/tests/api/tests/api_v3/credential.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 mod create; mod delete; +mod ec2; mod list; mod show; mod update; diff --git a/tests/api/tests/api_v3/credential/ec2.rs b/tests/api/tests/api_v3/credential/ec2.rs new file mode 100644 index 000000000..3e3120b01 --- /dev/null +++ b/tests/api/tests/api_v3/credential/ec2.rs @@ -0,0 +1,271 @@ +// 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 +//! OS-EC2 credential authorization matrix (issue #993). +//! +//! | endpoint (under /v3/users/{uid}/credentials/OS-EC2) | 2xx | 403 | 401 | +//! |------------------------------------------------------|-----|-----|-----| +//! | POST / | `test_ec2_credential_create_success_owner` | `test_ec2_credential_create_forbidden_cross_user` | `test_ec2_credential_create_unauthorized` | +//! | GET /{access} | `test_ec2_credential_show_success_owner` | `test_ec2_credential_show_forbidden_cross_user` | `test_ec2_credential_show_unauthorized` | +//! | GET / | `test_ec2_credential_list_success_owner` | `test_ec2_credential_list_forbidden_cross_user` | `test_ec2_credential_list_unauthorized` | +//! | DELETE /{access} | `test_ec2_credential_delete_success_owner` | `test_ec2_credential_delete_forbidden_cross_user` | `test_ec2_credential_delete_unauthorized` | +//! +//! Policy (`policy/os_ec2/*.rego`): admin, or a `member` acting on their +//! **own** `user_id`. The 403 fixture is therefore a real project-scoped +//! `member` operating on a *different* user's credentials. +//! +//! `Ec2Credential`'s derived `Debug` carries the plaintext secret, which the +//! assertion helpers print on an unexpected success, so asserted values are +//! reduced to their access key first. + +use std::sync::Arc; + +use eyre::Result; +use reqwest::StatusCode; + +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; + +use test_api::asserts::{assert_forbidden, assert_status, assert_unauthorized}; +use test_api::common::raw_request; +use test_api::credential::ec2::*; +use test_api::fixtures::{ProjectScopedUser, warn_on_cleanup_failure}; + +async fn admin_session() -> Result> { + Ok(Arc::new( + AsyncOpenStack::new(&CloudConfig::from_env()?).await?, + )) +} + +/// Two independent member users; `a` will attempt operations on `b`'s +/// credentials. +async fn two_members( + admin: &Arc, +) -> Result<(ProjectScopedUser, ProjectScopedUser)> { + let a = ProjectScopedUser::provision(admin, "default", "member").await?; + match ProjectScopedUser::provision(admin, "default", "member").await { + Ok(b) => Ok((a, b)), + Err(error) => { + warn_on_cleanup_failure("member fixture", a.cleanup().await); + Err(error) + } + } +} + +// --- create ------------------------------------------------------------- + +#[tokio::test] +async fn test_ec2_credential_create_success_owner() -> Result<()> { + let admin = admin_session().await?; + let owner = ProjectScopedUser::provision(&admin, "default", "member").await?; + + let cred = create_ec2_credential(&owner.session, &owner.user.id, &owner.project.id).await?; + assert!(!cred.access.is_empty(), "access key must be generated"); + assert!(!cred.secret.is_empty(), "secret must be returned on create"); + assert_eq!(cred.user_id, owner.user.id); + assert_eq!(cred.project_id, owner.project.id); + + delete_ec2_credential(&admin, &owner.user.id, &cred.access).await?; + owner.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_create_forbidden_cross_user() -> Result<()> { + let admin = admin_session().await?; + let (a, b) = two_members(&admin).await?; + + assert_forbidden( + create_ec2_credential(&a.session, &b.user.id, &b.project.id) + .await + .map(|cred| cred.access), + "a member must not create EC2 credentials for another user", + ); + + a.cleanup().await?; + b.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_create_unauthorized() -> Result<()> { + let rsp = raw_request( + http::Method::POST, + "v3/users/some-user/credentials/OS-EC2", + Some("invalid-token"), + Some(serde_json::json!({"tenant_id": "some-project"})), + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); + Ok(()) +} + +// --- show --------------------------------------------------------------- + +#[tokio::test] +async fn test_ec2_credential_show_success_owner() -> Result<()> { + let admin = admin_session().await?; + let owner = ProjectScopedUser::provision(&admin, "default", "member").await?; + let cred = create_ec2_credential(&owner.session, &owner.user.id, &owner.project.id).await?; + + let shown = get_ec2_credential(&owner.session, &owner.user.id, &cred.access).await?; + assert_eq!(shown.access, cred.access); + assert_eq!(shown.user_id, owner.user.id); + assert_eq!(shown.project_id, owner.project.id); + + delete_ec2_credential(&admin, &owner.user.id, &cred.access).await?; + owner.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_show_forbidden_cross_user() -> Result<()> { + let admin = admin_session().await?; + let (a, b) = two_members(&admin).await?; + let cred = create_ec2_credential(&b.session, &b.user.id, &b.project.id).await?; + + assert_forbidden( + get_ec2_credential(&a.session, &b.user.id, &cred.access) + .await + .map(|cred| cred.access), + "a member must not read another user's EC2 credential", + ); + + delete_ec2_credential(&admin, &b.user.id, &cred.access).await?; + a.cleanup().await?; + b.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_show_unauthorized() -> Result<()> { + let rsp = raw_request( + http::Method::GET, + "v3/users/some-user/credentials/OS-EC2/some-access", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); + Ok(()) +} + +// --- list --------------------------------------------------------------- + +#[tokio::test] +async fn test_ec2_credential_list_success_owner() -> Result<()> { + let admin = admin_session().await?; + let owner = ProjectScopedUser::provision(&admin, "default", "member").await?; + let cred = create_ec2_credential(&owner.session, &owner.user.id, &owner.project.id).await?; + + let creds = list_ec2_credentials( + &owner.session, + &owner.user.id, + Ec2CredentialListRequest::default(), + ) + .await?; + assert!( + creds.iter().any(|found| found.access == cred.access), + "owner's listing must contain the created credential" + ); + + delete_ec2_credential(&admin, &owner.user.id, &cred.access).await?; + owner.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_list_forbidden_cross_user() -> Result<()> { + let admin = admin_session().await?; + let (a, b) = two_members(&admin).await?; + + assert_forbidden( + list_ec2_credentials(&a.session, &b.user.id, Ec2CredentialListRequest::default()) + .await + .map(|creds| { + creds + .into_iter() + .map(|cred| cred.access) + .collect::>() + }), + "a member must not list another user's EC2 credentials", + ); + + a.cleanup().await?; + b.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_list_unauthorized() -> Result<()> { + let rsp = raw_request( + http::Method::GET, + "v3/users/some-user/credentials/OS-EC2", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); + Ok(()) +} + +// --- delete ------------------------------------------------------------- + +#[tokio::test] +async fn test_ec2_credential_delete_success_owner() -> Result<()> { + let admin = admin_session().await?; + let owner = ProjectScopedUser::provision(&admin, "default", "member").await?; + let cred = create_ec2_credential(&owner.session, &owner.user.id, &owner.project.id).await?; + + delete_ec2_credential(&owner.session, &owner.user.id, &cred.access).await?; + assert_status( + get_ec2_credential(&admin, &owner.user.id, &cred.access) + .await + .map(|cred| cred.access), + StatusCode::NOT_FOUND, + "deleted EC2 credential must be gone", + ); + + owner.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_delete_forbidden_cross_user() -> Result<()> { + let admin = admin_session().await?; + let (a, b) = two_members(&admin).await?; + let cred = create_ec2_credential(&b.session, &b.user.id, &b.project.id).await?; + + assert_forbidden( + delete_ec2_credential(&a.session, &b.user.id, &cred.access).await, + "a member must not delete another user's EC2 credential", + ); + + // The credential must have survived; admin cleans up. + delete_ec2_credential(&admin, &b.user.id, &cred.access).await?; + a.cleanup().await?; + b.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_ec2_credential_delete_unauthorized() -> Result<()> { + let rsp = raw_request( + http::Method::DELETE, + "v3/users/some-user/credentials/OS-EC2/some-access", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); + Ok(()) +} From 1baada5c442e1c52c11184372294fbb07184d92c Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 23 Jul 2026 18:21:25 -0400 Subject: [PATCH 4/9] test(api): Cover GET /v3/users/{id}/groups Cover an admin reading empty and populated memberships, a project-scoped member receiving 403 for another user, and an invalid token receiving 401. Create the populated membership through the only live write path, SCIM, then verify it through v3 in the SCIM binary to preserve the v3 binary's Python Keystone compatibility. Explicitly remove and purge every resource. Route the invalid-token case through assert_unauthorized, release the first fixture when the second fails to provision, and name the tests in the coverage table exactly as they are declared so the table stays greppable. Signed-off-by: ShJ-code --- tests/api/src/identity/user.rs | 12 ++ tests/api/tests/api_v3/identity.rs | 1 + .../api/tests/api_v3/identity/user_groups.rs | 92 ++++++++++ tests/api/tests/scim_v2.rs | 7 +- tests/api/tests/scim_v2/common.rs | 19 +++ tests/api/tests/scim_v2/v3_user_groups.rs | 159 ++++++++++++++++++ 6 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 tests/api/tests/api_v3/identity/user_groups.rs create mode 100644 tests/api/tests/scim_v2/v3_user_groups.rs diff --git a/tests/api/src/identity/user.rs b/tests/api/src/identity/user.rs index 12baa4de2..d0c4316d8 100644 --- a/tests/api/src/identity/user.rs +++ b/tests/api/src/identity/user.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 //! v3 user CRUD helpers, generated with [`crate::macros::crud_endpoint`]. +use openstack_keystone_api_types::v3::group::Group; use openstack_keystone_api_types::v3::user::*; use crate::macros::crud_endpoint; @@ -47,4 +48,15 @@ crud_endpoint! { service = Identity, api_version = (3, 0), } + list { + request = UserGroupsRequest, + func = list_user_groups, + parent = ("users", user_id), + path = "groups", + model = Group, + response_key = "groups", + service = Identity, + api_version = (3, 0), + query = [], + } } diff --git a/tests/api/tests/api_v3/identity.rs b/tests/api/tests/api_v3/identity.rs index 3b0e69e09..83c5813e9 100644 --- a/tests/api/tests/api_v3/identity.rs +++ b/tests/api/tests/api_v3/identity.rs @@ -13,3 +13,4 @@ // SPDX-License-Identifier: Apache-2.0 mod group; mod user; +mod user_groups; diff --git a/tests/api/tests/api_v3/identity/user_groups.rs b/tests/api/tests/api_v3/identity/user_groups.rs new file mode 100644 index 000000000..9d5616ad3 --- /dev/null +++ b/tests/api/tests/api_v3/identity/user_groups.rs @@ -0,0 +1,92 @@ +// 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 +//! `GET /v3/users/{user_id}/groups` authorization matrix (issue #993). +//! +//! | case | test | +//! |------|------| +//! | admin reads a fresh user's (empty) memberships | `test_user_groups_success_admin_empty` | +//! | admin reads a SCIM-populated membership | `scim_v2/v3_user_groups.rs::test_scim_membership_is_visible_in_v3_user_groups` | +//! | project-scoped user reads another user (policy `identity/user/show`) | `test_user_groups_forbidden_project_scoped_user` | +//! | invalid token | `test_user_groups_unauthorized` | +//! +//! A populated-listing case is exercised from the SCIM suite because SCIM is +//! the only live write path for memberships; keeping that cross-protocol setup +//! out of this binary preserves its Python Keystone compatibility. + +use std::sync::Arc; + +use eyre::Result; + +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; + +use test_api::asserts::{assert_forbidden, assert_unauthorized}; +use test_api::common::raw_request; +use test_api::fixtures::{ProjectScopedUser, warn_on_cleanup_failure}; +use test_api::identity::user::{UserGroupsRequest, list_user_groups}; + +async fn admin_session() -> Result> { + Ok(Arc::new( + AsyncOpenStack::new(&CloudConfig::from_env()?).await?, + )) +} + +#[tokio::test] +async fn test_user_groups_success_admin_empty() -> Result<()> { + let admin = admin_session().await?; + let fixture = ProjectScopedUser::provision(&admin, "default", "member").await?; + + let groups = list_user_groups(&admin, &fixture.user.id, UserGroupsRequest::default()).await?; + assert!( + groups.is_empty(), + "a freshly created user must have no group memberships, got: {groups:?}" + ); + + fixture.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_user_groups_forbidden_project_scoped_user() -> Result<()> { + let admin = admin_session().await?; + let a = ProjectScopedUser::provision(&admin, "default", "member").await?; + let b = match ProjectScopedUser::provision(&admin, "default", "member").await { + Ok(b) => b, + Err(error) => { + warn_on_cleanup_failure("member fixture", a.cleanup().await); + return Err(error); + } + }; + + assert_forbidden( + list_user_groups(&a.session, &b.user.id, UserGroupsRequest::default()).await, + "a project-scoped user must not read another user's group memberships", + ); + + a.cleanup().await?; + b.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn test_user_groups_unauthorized() -> Result<()> { + let rsp = raw_request( + http::Method::GET, + "v3/users/some-user/groups", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); + Ok(()) +} diff --git a/tests/api/tests/scim_v2.rs b/tests/api/tests/scim_v2.rs index 01819696d..c175a6aed 100644 --- a/tests/api/tests/scim_v2.rs +++ b/tests/api/tests/scim_v2.rs @@ -15,9 +15,9 @@ //! //! A sibling of `/v3`/`/v4`, not nested under `api_v4/`: the SCIM ingress //! surface is a bespoke bearer-token protocol (ADR 0021 §4, Sub-Router -//! Isolation), not part of the OpenStack-catalog API. Each submodule here is -//! the live-HTTP-and-live-OPA counterpart of an existing mocked-state -//! handler test file under `crates/keystone/src/scim/`. +//! Isolation), not part of the OpenStack-catalog API. The submodules include +//! live-HTTP-and-live-OPA counterparts of mocked-state handler tests under +//! `crates/keystone/src/scim/`, plus cross-protocol integration cases. mod scim_v2 { mod bulk_and_me; @@ -37,4 +37,5 @@ mod scim_v2 { mod patch; mod schemas_validation; mod user; + mod v3_user_groups; } diff --git a/tests/api/tests/scim_v2/common.rs b/tests/api/tests/scim_v2/common.rs index d75c43500..e61238474 100644 --- a/tests/api/tests/scim_v2/common.rs +++ b/tests/api/tests/scim_v2/common.rs @@ -46,6 +46,9 @@ use test_api::scim_realm::*; /// other resources are explicitly torn down via `cleanup()`. pub struct ProvisionedScim { pub client: ScimTestClient, + admin: Arc, + domain_id: String, + provider_id: String, idp: AsyncResourceGuard, api_key: AsyncResourceGuard, ruleset: AsyncResourceGuard, @@ -79,6 +82,19 @@ async fn delete_or_warn(resource: impl ResourceGuard) -> Result<()> { } impl ProvisionedScim { + /// Permanently remove one already-deprovisioned SCIM resource so tests do + /// not leave tombstones or membership rows on a long-lived API server. + pub async fn purge_resource(&self, resource_type: &str, keystone_id: &str) -> Result<()> { + test_api::scim_realm::purge_resource( + &self.admin, + &self.domain_id, + &self.provider_id, + resource_type, + keystone_id, + ) + .await + } + pub async fn cleanup(self) -> Result<()> { delete_or_warn(self.ruleset).await?; delete_or_warn(self.api_key).await?; @@ -185,6 +201,9 @@ async fn provision(grant_role: bool) -> Result { Ok(ProvisionedScim { client, + admin: admin.clone(), + domain_id, + provider_id, idp, api_key, ruleset, diff --git a/tests/api/tests/scim_v2/v3_user_groups.rs b/tests/api/tests/scim_v2/v3_user_groups.rs new file mode 100644 index 000000000..641d49ec3 --- /dev/null +++ b/tests/api/tests/scim_v2/v3_user_groups.rs @@ -0,0 +1,159 @@ +// 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 +//! Cross-protocol proof that SCIM membership writes are visible through +//! `GET /v3/users/{id}/groups` (issue #993). + +use std::sync::Arc; + +use eyre::Result; +use reqwest::StatusCode; +use serde_json::json; +use uuid::Uuid; + +use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; + +use test_api::identity::user::{UserGroupsRequest, list_user_groups}; +use test_api::scim::{ + PATCH_SCHEMA, ScimGroup, ScimGroupMember, ScimGroupWrite, ScimPatchOperation, ScimPatchRequest, + ScimUser, ScimUserWrite, expect_ok, +}; + +use super::common::{ProvisionedScim, provision_scim_realm}; + +async fn delete_and_purge_user(provisioned: &ProvisionedScim, user_id: &str) -> Result<()> { + let delete_result = provisioned.client.delete_user(user_id).await; + let purge_result = provisioned.purge_resource("user", user_id).await; + + let deleted = delete_result?; + eyre::ensure!( + deleted.status == StatusCode::NO_CONTENT, + "SCIM user cleanup returned {} instead of 204", + deleted.status + ); + purge_result +} + +async fn delete_and_purge_membership_resources( + provisioned: &ProvisionedScim, + group_id: &str, + user_id: &str, +) -> Result<()> { + // SCIM deprovisioning intentionally retains memberships for tombstone + // auditability, while the immediate purge endpoint enforces the identity + // backend's foreign keys. Remove the membership explicitly before + // tombstoning and purging both records. + let patch = ScimPatchRequest { + schemas: vec![PATCH_SCHEMA.to_string()], + operations: vec![ScimPatchOperation { + op: "remove".to_string(), + path: Some("members".to_string()), + value: json!([{ "value": user_id }]), + }], + }; + let membership_cleanup: Result<()> = async { + let patched: ScimGroup = + expect_ok(provisioned.client.patch_group(group_id, &patch).await?).await?; + eyre::ensure!( + patched.members.is_empty(), + "SCIM membership removal left members behind" + ); + Ok(()) + } + .await; + + // Execute every cleanup operation before propagating an individual error + // so one failed deletion does not prevent the other resource from being + // cleaned up. + let group_delete = provisioned.client.delete_group(group_id).await; + let user_delete = provisioned.client.delete_user(user_id).await; + let group_purge = provisioned.purge_resource("group", group_id).await; + let user_purge = provisioned.purge_resource("user", user_id).await; + + membership_cleanup?; + let group_delete = group_delete?; + let user_delete = user_delete?; + eyre::ensure!( + group_delete.status == StatusCode::NO_CONTENT, + "SCIM group cleanup returned {} instead of 204", + group_delete.status + ); + eyre::ensure!( + user_delete.status == StatusCode::NO_CONTENT, + "SCIM user cleanup returned {} instead of 204", + user_delete.status + ); + group_purge?; + user_purge?; + Ok(()) +} + +#[tokio::test] +async fn test_scim_membership_is_visible_in_v3_user_groups() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let provisioned = provision_scim_realm().await?; + let member_result: Result = async { + expect_ok( + provisioned + .client + .create_user(&ScimUserWrite::new(format!( + "scim-user-{}", + Uuid::new_v4().simple() + ))) + .await?, + ) + .await + } + .await; + let member = match member_result { + Ok(member) => member, + Err(error) => { + provisioned.cleanup().await?; + return Err(error); + } + }; + + let display_name = format!("scim-group-{}", Uuid::new_v4().simple()); + let mut group_write = ScimGroupWrite::new(&display_name); + group_write.members = vec![ScimGroupMember { + value: member.id.clone(), + }]; + let group_result: Result = + async { expect_ok(provisioned.client.create_group(&group_write).await?).await }.await; + let group = match group_result { + Ok(group) => group, + Err(error) => { + let resource_cleanup = delete_and_purge_user(&provisioned, &member.id).await; + let fixture_cleanup = provisioned.cleanup().await; + resource_cleanup?; + fixture_cleanup?; + return Err(error); + } + }; + + let list_result = list_user_groups(&admin, &member.id, UserGroupsRequest::default()).await; + let resource_cleanup = + delete_and_purge_membership_resources(&provisioned, &group.id, &member.id).await; + let fixture_cleanup = provisioned.cleanup().await; + + let groups = list_result?; + resource_cleanup?; + fixture_cleanup?; + + assert_eq!(groups.len(), 1, "the SCIM membership must be visible in v3"); + let listed = &groups[0]; + assert_eq!(listed.id, group.id); + assert_eq!(listed.name, display_name); + assert_eq!(listed.domain_id, "default"); + Ok(()) +} From ccb49f7dc11f6937243566228c0f134f35acd2a3 Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 23 Jul 2026 18:21:25 -0400 Subject: [PATCH 5/9] test(api): Cover role inference authorization branches Keep the admin positive case and add the policy's system-scoped reader positive branch, project-scoped member denial, and invalid-token rejection. Add a reusable system-scoped user fixture and system role-grant endpoint, with admin-owned cleanup for both success and provisioning failures, and route the invalid-token case through assert_unauthorized. Signed-off-by: ShJ-code --- tests/api/src/assignment.rs | 49 ++++++++++++++- tests/api/src/fixtures.rs | 62 ++++++++++++++++++- .../api_v3/role/imply/list_inferences.rs | 47 ++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/tests/api/src/assignment.rs b/tests/api/src/assignment.rs index 211c83ae4..839899b6b 100644 --- a/tests/api/src/assignment.rs +++ b/tests/api/src/assignment.rs @@ -20,7 +20,7 @@ use eyre::Result; use openstack_sdk::api::rest_endpoint_prelude::*; use openstack_sdk::{AsyncOpenStack, api::QueryAsync}; -/// Grant a role to a user on a project. +/// Grant roles to users on project and system scopes. pub mod grant { use super::*; @@ -54,6 +54,31 @@ pub mod grant { } } + #[derive(Builder, Clone, Debug)] + #[builder(setter(strip_option, into))] + struct SystemUserRoleGrant<'a> { + user_id: Cow<'a, str>, + role_id: Cow<'a, str>, + } + + impl RestEndpoint for SystemUserRoleGrant<'_> { + fn method(&self) -> http::Method { + http::Method::PUT + } + + fn endpoint(&self) -> Cow<'static, str> { + format!("system/users/{}/roles/{}", self.user_id, self.role_id).into() + } + + fn service_type(&self) -> ServiceType { + ServiceType::Identity + } + + fn api_version(&self) -> Option { + Some(ApiVersion::new(3, 0)) + } + } + /// Grant `role_id` to `user_id` on `project_id`. The grant is a PUT and is /// cleaned up implicitly when the project or user is deleted. pub async fn add_project_grant( @@ -78,4 +103,26 @@ pub mod grant { .await?; Ok(()) } + + /// Grant `role_id` to `user_id` on the system scope. The grant is cleaned + /// up implicitly when the user is deleted. + pub async fn add_system_grant( + client: &Arc, + user_id: U, + role_id: R, + ) -> Result<()> + where + U: AsRef, + R: AsRef, + { + openstack_sdk::api::ignore( + SystemUserRoleGrantBuilder::default() + .user_id(user_id.as_ref()) + .role_id(role_id.as_ref()) + .build()?, + ) + .query_async(client.as_ref()) + .await?; + Ok(()) + } } diff --git a/tests/api/src/fixtures.rs b/tests/api/src/fixtures.rs index 5e20d5fcc..452a32e50 100644 --- a/tests/api/src/fixtures.rs +++ b/tests/api/src/fixtures.rs @@ -18,12 +18,14 @@ use std::sync::Arc; use eyre::{OptionExt, Result}; use uuid::Uuid; -use openstack_keystone_api_types::scope::{DomainBuilder, Scope, ScopeProjectBuilder}; +use openstack_keystone_api_types::scope::{ + DomainBuilder, Scope, ScopeProjectBuilder, System as ScopeSystem, +}; use openstack_keystone_api_types::v3::project::{Project, ProjectCreateBuilder}; use openstack_keystone_api_types::v3::user::{User, UserCreateBuilder}; use openstack_sdk::AsyncOpenStack; -use crate::assignment::grant::add_project_grant; +use crate::assignment::grant::{add_project_grant, add_system_grant}; use crate::common::get_user_session; use crate::guard::{AsyncResourceGuard, ResourceGuard}; use crate::identity::user::create_user; @@ -127,3 +129,59 @@ impl ProjectScopedUser { Ok(()) } } + +/// A real password user holding one role on the system scope and authenticated +/// with a system-scoped token through the live password-auth path. +pub struct SystemScopedUser { + /// System-scoped session authenticated as the fixture user. + pub session: Arc, + /// The fixture user, deleted by [`Self::cleanup`]. + pub user: AsyncResourceGuard, +} + +impl SystemScopedUser { + /// Provision a user with `role_name` granted on `system: all`. + pub async fn provision( + admin: &Arc, + domain_id: &str, + role_name: &str, + ) -> Result { + let unique = Uuid::new_v4().simple().to_string(); + let user = create_user( + admin, + UserCreateBuilder::default() + .name(format!("fix-system-usr-{unique}")) + .domain_id(domain_id) + .password(FIXTURE_PASSWORD) + .enabled(true) + .build()?, + ) + .await?; + let session_result = async { + let role = list_roles(admin) + .await? + .into_iter() + .find(|role| role.name == role_name) + .ok_or_eyre(format!("bootstrap `{role_name}` role must exist"))?; + add_system_grant(admin, &user.id, &role.id).await?; + + let scope = Scope::System(ScopeSystem { all: Some(true) }); + get_user_session(&user.name, FIXTURE_PASSWORD, domain_id, Some(&scope)).await + } + .await; + + match session_result { + Ok(session) => Ok(Self { session, user }), + Err(error) => { + warn_on_cleanup_failure("fixture user", user.delete().await); + Err(error) + } + } + } + + /// Delete the fixture user; its system role grant is removed implicitly. + pub async fn cleanup(self) -> Result<()> { + self.user.delete().await?; + Ok(()) + } +} diff --git a/tests/api/tests/api_v3/role/imply/list_inferences.rs b/tests/api/tests/api_v3/role/imply/list_inferences.rs index b25c03745..f29c20348 100644 --- a/tests/api/tests/api_v3/role/imply/list_inferences.rs +++ b/tests/api/tests/api_v3/role/imply/list_inferences.rs @@ -21,6 +21,9 @@ use uuid::Uuid; use openstack_keystone_api_types::v3::role::*; use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; +use test_api::asserts::{assert_forbidden, assert_unauthorized}; +use test_api::common::raw_request; +use test_api::fixtures::{ProjectScopedUser, SystemScopedUser}; use test_api::role::imply::*; use test_api::role::{create_role, delete_role}; @@ -54,3 +57,47 @@ async fn test_list_role_inferences() -> Result<()> { delete_role(&tc, &prior_role.id).await?; Ok(()) } + +/// `policy/role/imply_rule/list.rego` allows only `admin` (or a +/// system-scoped `reader`); a project-scoped member must be denied. +#[tokio::test] +async fn test_list_role_inferences_forbidden_project_scoped_member() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let member = ProjectScopedUser::provision(&admin, "default", "member").await?; + + assert_forbidden( + list_role_inferences(&member.session).await, + "a project-scoped member must not list global role inferences", + ); + + member.cleanup().await?; + Ok(()) +} + +/// Exercise the policy's non-admin positive branch: a reader is permitted only +/// when the token is scoped to `system: all`. +#[tokio::test] +async fn test_list_role_inferences_allowed_system_scoped_reader() -> Result<()> { + let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); + let reader = SystemScopedUser::provision(&admin, "default", "reader").await?; + + let list_result = list_role_inferences(&reader.session).await; + let cleanup_result = reader.cleanup().await; + + list_result?; + cleanup_result?; + Ok(()) +} + +#[tokio::test] +async fn test_list_role_inferences_unauthorized() -> Result<()> { + let rsp = raw_request( + http::Method::GET, + "v3/role_inferences", + Some("invalid-token"), + None, + ) + .await?; + assert_unauthorized(rsp.error_for_status(), "an invalid token must be rejected"); + Ok(()) +} From 634dec0fd33e8c01accd689e772ac4e923efc097 Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 30 Jul 2026 19:04:26 +0000 Subject: [PATCH 6/9] fix(auth): Reject ordinary EC2 token authentication Return 403 when an EC2-issued token is presented as X-Auth-Token, while preserving subject-token validation and token-to-token reauth. Checks both the bare AuthenticationContext::Ec2Credential variant and the immutable ec2credential auth-method marker, since a credential minted under a trust or application credential reconstructs the Trust/ApplicationCredential context on redemption instead. Use the shared #992 status assertions and complete fixture cleanup before panicable assertions in the negative cases. Signed-off-by: ShJ-code --- crates/core/src/api/auth.rs | 107 ++++++++++++++++++++++- tests/api/tests/api_v3/auth/ec2tokens.rs | 91 +++++++++++++------ 2 files changed, 169 insertions(+), 29 deletions(-) diff --git a/crates/core/src/api/auth.rs b/crates/core/src/api/auth.rs index ea69ebf71..9b813ab28 100644 --- a/crates/core/src/api/auth.rs +++ b/crates/core/src/api/auth.rs @@ -240,14 +240,25 @@ fn flat_spiffe_claims(svid: &SpiffeId) -> MappingAuthRequest { /// /// # Returns /// -/// `Ok(())` when the auth type is not EC2, `Err(403 Forbidden)` otherwise. +/// `Ok(())` when the auth type is not EC2, `Err(400 Bad Request)` otherwise +/// (`KeystoneApiError::SelectedAuthenticationForbidden` — the same variant +/// used for `AuthenticationError::TokenRenewalForbidden` — always maps to +/// 400, never 403, per `error_conv.rs`). fn reject_if_ec2(user_auth: &ValidatedSecurityContext) -> Result<(), KeystoneApiError> { // SECURITY: this must stay here unchanged to prevent security vulnerabilities // from the EC2 auth reuse. + // + // A bare EC2 credential (not minted under a trust/app-cred) surfaces as + // `AuthenticationContext::Ec2Credential` directly. One minted under a + // trust/app-cred reconstructs `AuthenticationContext::Trust` / + // `ApplicationCredential` on redemption instead (security-model.md §5), + // so it must also be caught via the immutable `ec2credential` marker in + // the auth-method chain, which survives that reconstruction. if matches!( user_auth.inner().authentication_context(), AuthenticationContext::Ec2Credential - ) { + ) || user_auth.inner().auth_methods().contains("ec2credential") + { return Err(KeystoneApiError::SelectedAuthenticationForbidden); } Ok(()) @@ -868,4 +879,96 @@ mod tests { KeystoneApiError::SelectedAuthenticationForbidden )); } + + #[tokio::test] + async fn test_x_auth_token_ec2credential_method_rejected() { + use crate::token::MockTokenProvider; + + let config = Config::default(); + let config_manager = ConfigManager::not_watched(config); + let policy_enforcer = Arc::new(MockPolicy::default()); + let db = sea_orm::DatabaseConnection::default(); + + let mut token_mock = MockTokenProvider::new(); + token_mock.expect_authorize_by_token().once().returning( + move |_exec, _token, _allow_rescope, _restrict_to| { + let mut security_context = SecurityContextTestingBuilder::default() + .authentication_context(AuthenticationContext::Token( + openstack_keystone_core_types::token::FernetToken::ProjectScope( + openstack_keystone_core_types::token::ProjectScopePayload { + user_id: "token-user".into(), + methods: vec!["ec2credential".into()], + audit_ids: vec![], + expires_at: chrono::Utc::now(), + project_id: "token-project".into(), + ..Default::default() + }, + ), + )) + .principal( + PrincipalInfoBuilder::default() + .identity(IdentityInfo::Principal( + PrincipalIdentityInfoBuilder::default() + .id("token-user") + .issuer("test.domain") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ) + .build(); + // Fully resolve the context by setting authorization + security_context.set_authorization_scope(ScopeInfo::Unscoped)?; + Ok(ValidatedSecurityContext::test_new(security_context)) + }, + ); + + let provider = Provider::mocked_builder() + .mock_mapping(MockMappingProvider::new()) + .mock_token(token_mock) + .build() + .unwrap(); + let state = Arc::new(Service { + config_manager, + db, + policy_enforcer, + provider, + event_dispatcher: crate::events::EventDispatcher::production(), + + audit_dispatcher: openstack_keystone_audit::AuditDispatcher::noop(), + + storage: None, + local_emergency_store: tokio::sync::RwLock::new(None), + spiffe_health_check: tokio::sync::RwLock::new(None), + local_emergency_leaderless_tracker: + openstack_keystone_local_emergency_store::LeaderlessTracker::new(), + api_key_rate_limiter: std::sync::Arc::new(governor::RateLimiter::keyed( + governor::Quota::per_minute(std::num::NonZeroU32::new(60).unwrap()), + )), + oauth2_token_rate_limiter: std::sync::Arc::new(governor::RateLimiter::keyed( + governor::Quota::per_minute(std::num::NonZeroU32::new(60).unwrap()), + )), + auth_plugin_registry: tokio::sync::RwLock::new(Arc::new( + openstack_keystone_auth_plugin_runtime::WasmPluginRegistry::default(), + )), + core_host_functions: tokio::sync::RwLock::new(None), + rate_limiters: crate::rate_limit::RateLimitState::default(), + auth_plugin_limiters: tokio::sync::RwLock::new(std::collections::HashMap::new()), + auth_plugin_load_failures: tokio::sync::RwLock::new(std::collections::HashMap::new()), + shutdown: false, + }); + + let mut parts = make_parts(); + parts + .headers + .insert("X-Auth-Token", "valid-token-string".parse().unwrap()); + + let result = Auth::from_request_parts(&mut parts, &state).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + KeystoneApiError::SelectedAuthenticationForbidden + )); + } } diff --git a/tests/api/tests/api_v3/auth/ec2tokens.rs b/tests/api/tests/api_v3/auth/ec2tokens.rs index e8b974268..abd01e361 100644 --- a/tests/api/tests/api_v3/auth/ec2tokens.rs +++ b/tests/api/tests/api_v3/auth/ec2tokens.rs @@ -26,6 +26,7 @@ //! | wrong signature | `test_ec2_token_bad_signature_rejected` | //! | stale timestamp | `test_ec2_token_stale_timestamp_rejected` | //! | issued token validates at /v3/auth/tokens | `test_ec2_token_validates_at_auth_tokens` | +//! | issued token is rejected as ordinary X-Auth-Token | `test_ec2_token_rejected_as_x_auth_token` | //! | token-from-token reauth still allowed | `test_ec2_token_reauth_allowed` | //! //! The signature is produced by `test_api::auth::ec2`'s independent SigV2 @@ -44,9 +45,9 @@ use openstack_keystone_api_types::v3::auth::token::TokenResponse; use openstack_keystone_api_types::v3::os_ec2_credential::Ec2Credential; use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; -use test_api::asserts::{assert_forbidden, assert_unauthorized}; +use test_api::asserts::{assert_forbidden, assert_status, assert_unauthorized}; use test_api::auth::ec2::{ec2_token_request_body, post_ec2_token, post_ec2_token_extract}; -use test_api::common::TestClient; +use test_api::common::{TestClient, raw_request}; use test_api::credential::ec2::{create_ec2_credential, delete_ec2_credential}; use test_api::fixtures::{FIXTURE_PASSWORD, ProjectScopedUser, warn_on_cleanup_failure}; @@ -87,9 +88,11 @@ async fn cleanup( member: ProjectScopedUser, cred: &Ec2Credential, ) -> Result<()> { - delete_ec2_credential(admin, &member.user.id, &cred.access).await?; - member.cleanup().await?; - Ok(()) + let credential_cleanup_result = + delete_ec2_credential(admin, &member.user.id, &cred.access).await; + let member_cleanup_result = member.cleanup().await; + credential_cleanup_result?; + member_cleanup_result } #[tokio::test] @@ -157,13 +160,17 @@ async fn test_ec2_token_forbidden_member_caller() -> Result<()> { .to_string(); let body = ec2_token_request_body(&cred.access, &cred.secret, None, None)?; - let response = post_ec2_token(Some(&member_token), body).await?; + let response_result = post_ec2_token(Some(&member_token), body).await; + let cleanup_result = cleanup(&admin, member, &cred).await; + + let response = response_result?; + cleanup_result?; assert_forbidden( - response.error_for_status(), + response + .error_for_status() + .map(|response| response.status()), "member callers must be denied by policy", ); - - cleanup(&admin, member, &cred).await?; Ok(()) } @@ -173,16 +180,21 @@ async fn test_ec2_token_bad_signature_rejected() -> Result<()> { let (member, cred) = member_with_credential(&admin).await?; let body = ec2_token_request_body(&cred.access, &cred.secret, None, Some("bogus-signature"))?; - let (status, subject_token, _) = - post_ec2_token_extract(Some(&admin_token().await?), body).await?; - assert_eq!( - status, - StatusCode::UNAUTHORIZED, - "a wrong signature must not authenticate" - ); - assert!(subject_token.is_none(), "no token may be issued"); + let response_result = post_ec2_token(Some(&admin_token().await?), body).await; + let cleanup_result = cleanup(&admin, member, &cred).await; - cleanup(&admin, member, &cred).await?; + let response = response_result?; + cleanup_result?; + assert!( + !response.headers().contains_key("X-Subject-Token"), + "no token may be issued" + ); + assert_unauthorized( + response + .error_for_status() + .map(|response| response.status()), + "a wrong signature must not authenticate", + ); Ok(()) } @@ -199,16 +211,21 @@ async fn test_ec2_token_stale_timestamp_rejected() -> Result<()> { Some("2011-10-03T15:19:30Z".to_string()), None, )?; - let (status, subject_token, _) = - post_ec2_token_extract(Some(&admin_token().await?), body).await?; - assert_eq!( - status, - StatusCode::UNAUTHORIZED, - "a stale signed request must not authenticate" - ); - assert!(subject_token.is_none(), "no token may be issued"); + let response_result = post_ec2_token(Some(&admin_token().await?), body).await; + let cleanup_result = cleanup(&admin, member, &cred).await; - cleanup(&admin, member, &cred).await?; + let response = response_result?; + cleanup_result?; + assert!( + !response.headers().contains_key("X-Subject-Token"), + "no token may be issued" + ); + assert_unauthorized( + response + .error_for_status() + .map(|response| response.status()), + "a stale signed request must not authenticate", + ); Ok(()) } @@ -267,6 +284,26 @@ async fn test_ec2_token_validates_at_auth_tokens() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_ec2_token_rejected_as_x_auth_token() -> Result<()> { + let admin = admin_session().await?; + let (member, cred, token) = issue_ec2_token(&admin).await?; + + let response_result = raw_request(http::Method::GET, "v3/projects", Some(&token), None).await; + let cleanup_result = cleanup(&admin, member, &cred).await; + + let response = response_result?; + cleanup_result?; + assert_status( + response + .error_for_status() + .map(|response| response.status()), + StatusCode::BAD_REQUEST, + "an EC2-issued token must not authenticate ordinary API requests", + ); + Ok(()) +} + #[tokio::test] async fn test_ec2_token_reauth_allowed() -> Result<()> { let admin = admin_session().await?; From 7b2c7f72e4301cc4e76e8557f625bf3573381263 Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 30 Jul 2026 19:04:29 +0000 Subject: [PATCH 7/9] fix(identity): Filter unreadable user groups Re-enforce identity/group/show against every returned group and omit items denied by policy, as required by security-model invariant I8. Add reusable caller-configured policy state and focused tests that verify both per-record policy inputs and denied-item filtering. Signed-off-by: ShJ-code --- crates/core/src/api.rs | 27 ++++ crates/keystone/src/api/mod.rs | 3 +- crates/keystone/src/api/v3/user/groups.rs | 187 +++++++++++++++++++++- crates/keystone/src/api/v4/user/groups.rs | 28 +++- 4 files changed, 232 insertions(+), 13 deletions(-) diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index d185b43c4..1676052ba 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -246,6 +246,33 @@ pub mod tests { ) } + /// Initialize service state with a caller-configured policy mock. + /// + /// This supports handler tests whose policy decision depends on the + /// individual resource instead of a single allow/deny value. + pub async fn get_state_with_mock_policy( + provider_builder: ProviderBuilder, + mut policy_enforcer_mock: MockPolicy, + ) -> ServiceState { + let provider = provider_builder.build().unwrap(); + policy_enforcer_mock + .expect_health_check() + .returning(|| Ok(())); + + Arc::new( + Service::new( + ConfigManager::not_watched(Config::default()), + DatabaseConnection::default(), + provider, + Arc::new(policy_enforcer_mock), + AuditDispatcher::noop(), + None, + ) + .await + .unwrap(), + ) + } + /// One recorded `enforce()` call, as observed by [`CapturingPolicy`]. /// /// This is Gate B2 (security review V3a, issue #978): the shared, diff --git a/crates/keystone/src/api/mod.rs b/crates/keystone/src/api/mod.rs index 670cee528..3d28acebf 100644 --- a/crates/keystone/src/api/mod.rs +++ b/crates/keystone/src/api/mod.rs @@ -122,7 +122,8 @@ async fn version( pub(crate) mod tests { pub use openstack_keystone_core::api::policy_contract; pub use openstack_keystone_core::api::tests::{ - get_capturing_state, get_mocked_state, get_mocked_state_with_config, test_fixture_scoped, + get_capturing_state, get_mocked_state, get_mocked_state_with_config, + get_state_with_mock_policy, test_fixture_scoped, }; use std::net::SocketAddr; diff --git a/crates/keystone/src/api/v3/user/groups.rs b/crates/keystone/src/api/v3/user/groups.rs index 2bd51bb4e..7807d77da 100644 --- a/crates/keystone/src/api/v3/user/groups.rs +++ b/crates/keystone/src/api/v3/user/groups.rs @@ -24,6 +24,7 @@ use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; use crate::api::v3::group::types::{Group, GroupList}; use crate::keystone::ServiceState; +use crate::policy::PolicyError; use openstack_keystone_core::auth::ExecutionContext; /// List groups a user is member of @@ -70,14 +71,31 @@ pub(super) async fn groups( .await?; match current { Some(_) => { - let groups: Vec = state + let raw_groups = state .provider .get_identity_provider() .list_groups_of_user(&ExecutionContext::from_auth(&state, &user_auth), &user_id) - .await? - .into_iter() - .map(Into::into) - .collect(); + .await?; + + // CVE-2019-19687 / security-model I8: membership in a readable + // user must not reveal a group the caller cannot itself read. + let mut groups = Vec::with_capacity(raw_groups.len()); + for group in raw_groups { + match state + .policy_enforcer + .enforce( + "identity/group/show", + &user_auth, + serde_json::Value::Null, + Some(json!({"group": &group})), + ) + .await + { + Ok(_) => groups.push(Group::from(group)), + Err(PolicyError::Forbidden(_)) => continue, + Err(error) => return Err(error.into()), + } + } Ok(( StatusCode::OK, Json(GroupList { @@ -105,14 +123,169 @@ mod tests { use tower_http::trace::TraceLayer; use super::super::openapi_router; - use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::api::tests::{ + get_capturing_state, get_mocked_state, get_state_with_mock_policy, test_fixture_scoped, + }; use crate::identity::MockIdentityProvider; + use crate::policy::{MockPolicy, PolicyError, PolicyEvaluationResult}; use crate::{ api::v3::group::types::{GroupBuilder as ApiGroupBuilder, GroupList}, provider::Provider, }; use openstack_keystone_core_types::identity::Group; - use openstack_keystone_core_types::identity::UserResponseBuilder; + use openstack_keystone_core_types::identity::{UserResponse, UserResponseBuilder}; + + #[tokio::test] + async fn test_groups_rechecks_each_group_policy() -> eyre::Result<()> { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| { + Ok(Some(UserResponse { + default_project_id: None, + domain_id: "domain-a".into(), + enabled: true, + extra: Default::default(), + federated: None, + id: "user-id".into(), + name: "user-name".into(), + options: Default::default(), + password_expires_at: None, + })) + }); + identity_mock + .expect_list_groups_of_user() + .returning(|_, _| { + Ok(vec![ + Group { + id: "visible".into(), + name: "visible".into(), + domain_id: "domain-a".into(), + ..Default::default() + }, + Group { + id: "hidden".into(), + name: "hidden".into(), + domain_id: "domain-b".into(), + ..Default::default() + }, + ]) + }); + + let (state, policy) = + get_capturing_state(Provider::mocked_builder().mock_identity(identity_mock)).await; + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/user-id/groups") + .extension(test_fixture_scoped()) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let calls = policy.calls(); + assert_eq!(calls.len(), 3); + assert_eq!(calls[0].policy_name, "identity/user/show"); + for (call, expected_id) in calls.iter().skip(1).zip(["visible", "hidden"]) { + assert_eq!(call.policy_name, "identity/group/show"); + assert_eq!(call.target, serde_json::Value::Null); + assert_eq!( + call.existing + .as_ref() + .and_then(|value| value.pointer("/group/id")) + .and_then(serde_json::Value::as_str), + Some(expected_id), + ); + } + Ok(()) + } + + #[tokio::test] + async fn test_groups_drops_items_denied_by_show_policy() -> eyre::Result<()> { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, _| { + Ok(Some(UserResponse { + default_project_id: None, + domain_id: "domain-a".into(), + enabled: true, + extra: Default::default(), + federated: None, + id: "user-id".into(), + name: "user-name".into(), + options: Default::default(), + password_expires_at: None, + })) + }); + identity_mock + .expect_list_groups_of_user() + .returning(|_, _| { + Ok(vec![ + Group { + id: "visible".into(), + name: "visible".into(), + domain_id: "domain-a".into(), + ..Default::default() + }, + Group { + id: "hidden".into(), + name: "hidden".into(), + domain_id: "domain-b".into(), + ..Default::default() + }, + ]) + }); + + let mut policy = MockPolicy::default(); + policy + .expect_enforce() + .returning(|policy_name, _, _, existing| { + let hidden_group = policy_name == "identity/group/show" + && existing + .as_ref() + .and_then(|value| value.pointer("/group/id")) + .and_then(serde_json::Value::as_str) + == Some("hidden"); + if hidden_group { + Err(PolicyError::Forbidden(PolicyEvaluationResult::forbidden())) + } else { + Ok(PolicyEvaluationResult::allowed_admin()) + } + }); + + let state = get_state_with_mock_policy( + Provider::mocked_builder().mock_identity(identity_mock), + policy, + ) + .await; + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/user-id/groups") + .extension(test_fixture_scoped()) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await?.to_bytes(); + let groups: GroupList = serde_json::from_slice(&body)?; + assert_eq!( + groups + .groups + .into_iter() + .map(|group| group.id) + .collect::>(), + vec!["visible"], + ); + Ok(()) + } #[tokio::test] async fn test_groups() { diff --git a/crates/keystone/src/api/v4/user/groups.rs b/crates/keystone/src/api/v4/user/groups.rs index 3d3a86ef2..024b1909f 100644 --- a/crates/keystone/src/api/v4/user/groups.rs +++ b/crates/keystone/src/api/v4/user/groups.rs @@ -25,6 +25,7 @@ use crate::api::auth::Auth; use crate::api::error::KeystoneApiError; use crate::api::v3::group::types::{Group, GroupList}; use crate::keystone::ServiceState; +use crate::policy::PolicyError; use openstack_keystone_core::auth::ExecutionContext; /// List groups a user is member of @@ -63,14 +64,31 @@ pub(super) async fn groups( match current { Some(_) => { - let groups: Vec = state + let raw_groups = state .provider .get_identity_provider() .list_groups_of_user(&ExecutionContext::from_auth(&state, &user_auth), &user_id) - .await? - .into_iter() - .map(Into::into) - .collect(); + .await?; + + // CVE-2019-19687 / security-model I8: membership in a readable + // user must not reveal a group the caller cannot itself read. + let mut groups = Vec::with_capacity(raw_groups.len()); + for group in raw_groups { + match state + .policy_enforcer + .enforce( + "identity/group/show", + &user_auth, + serde_json::Value::Null, + Some(json!({"group": &group})), + ) + .await + { + Ok(_) => groups.push(Group::from(group)), + Err(PolicyError::Forbidden(_)) => continue, + Err(error) => return Err(error.into()), + } + } Ok(( StatusCode::OK, From 1edeeaeadb655bd1e5259431ed79a5a2c51454ec Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 30 Jul 2026 19:04:41 +0000 Subject: [PATCH 8/9] test(api): Harden fixture cleanup Attempt every project-scoped fixture deletion before returning an error, and reuse the helper across multi-user authorization tests. Complete cleanup before panicable assertions and retain the reason the live Rust server enables the ec2credential authentication method. Signed-off-by: ShJ-code --- tests/api/README.md | 8 ++++- tests/api/src/fixtures.rs | 29 +++++++++++++++++-- tests/api/tests/api_v3/credential/ec2.rs | 25 ++++++++-------- .../api/tests/api_v3/identity/user_groups.rs | 13 +++++---- .../api_v3/role/imply/list_inferences.rs | 8 +++-- tools/start-api.sh | 3 ++ 6 files changed, 62 insertions(+), 24 deletions(-) diff --git a/tests/api/README.md b/tests/api/README.md index 7b5277d20..51a3674ca 100644 --- a/tests/api/README.md +++ b/tests/api/README.md @@ -70,6 +70,9 @@ member.cleanup().await?; // Same, but with the role granted on `system: all`: let reader = SystemScopedUser::provision(&admin, "default", "reader").await?; reader.cleanup().await?; + +// For multiple project-scoped fixtures, attempt every cleanup even if one fails: +cleanup_project_scoped_users([first, second]).await?; ``` Both are provisioned *and* torn down with the admin session passed in, so @@ -77,7 +80,10 @@ cleanup never depends on the underprivileged session, and both undo their partial work if a later provisioning step fails. When you compose fixtures yourself, mirror that: on error, release what you already hold with `warn_on_cleanup_failure("…", fixture.cleanup().await)`, which logs a failed -best-effort cleanup instead of shadowing the error that triggered it. +best-effort cleanup instead of shadowing the error that triggered it. For normal +teardown of several project-scoped fixtures, use +`cleanup_project_scoped_users`, which attempts every deletion before returning +the first error. ### Invalid-auth requests (`test_api::common::raw_request`) diff --git a/tests/api/src/fixtures.rs b/tests/api/src/fixtures.rs index 452a32e50..255fbe565 100644 --- a/tests/api/src/fixtures.rs +++ b/tests/api/src/fixtures.rs @@ -124,9 +124,32 @@ impl ProjectScopedUser { /// Delete the fixture user and project with the admin session that /// created them. pub async fn cleanup(self) -> Result<()> { - self.user.delete().await?; - self.project.delete().await?; - Ok(()) + let user_cleanup_result = self.user.delete().await; + let project_cleanup_result = self.project.delete().await; + user_cleanup_result?; + project_cleanup_result + } +} + +/// Delete every project-scoped fixture, attempting all cleanups before +/// returning the first error. +pub async fn cleanup_project_scoped_users( + fixtures: impl IntoIterator, +) -> Result<()> { + let mut first_error = None; + for fixture in fixtures { + if let Err(error) = fixture.cleanup().await { + if first_error.is_none() { + first_error = Some(error); + } else { + tracing::warn!(?error, "additional project-scoped fixture cleanup failed"); + } + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), } } diff --git a/tests/api/tests/api_v3/credential/ec2.rs b/tests/api/tests/api_v3/credential/ec2.rs index 3e3120b01..75d331e70 100644 --- a/tests/api/tests/api_v3/credential/ec2.rs +++ b/tests/api/tests/api_v3/credential/ec2.rs @@ -38,7 +38,9 @@ use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; use test_api::asserts::{assert_forbidden, assert_status, assert_unauthorized}; use test_api::common::raw_request; use test_api::credential::ec2::*; -use test_api::fixtures::{ProjectScopedUser, warn_on_cleanup_failure}; +use test_api::fixtures::{ + ProjectScopedUser, cleanup_project_scoped_users, warn_on_cleanup_failure, +}; async fn admin_session() -> Result> { Ok(Arc::new( @@ -91,8 +93,7 @@ async fn test_ec2_credential_create_forbidden_cross_user() -> Result<()> { "a member must not create EC2 credentials for another user", ); - a.cleanup().await?; - b.cleanup().await?; + cleanup_project_scoped_users([a, b]).await?; Ok(()) } @@ -141,8 +142,7 @@ async fn test_ec2_credential_show_forbidden_cross_user() -> Result<()> { ); delete_ec2_credential(&admin, &b.user.id, &cred.access).await?; - a.cleanup().await?; - b.cleanup().await?; + cleanup_project_scoped_users([a, b]).await?; Ok(()) } @@ -188,7 +188,7 @@ async fn test_ec2_credential_list_forbidden_cross_user() -> Result<()> { let admin = admin_session().await?; let (a, b) = two_members(&admin).await?; - assert_forbidden( + let list_result = list_ec2_credentials(&a.session, &b.user.id, Ec2CredentialListRequest::default()) .await .map(|creds| { @@ -196,12 +196,14 @@ async fn test_ec2_credential_list_forbidden_cross_user() -> Result<()> { .into_iter() .map(|cred| cred.access) .collect::>() - }), + }); + let cleanup_result = cleanup_project_scoped_users([a, b]).await; + + cleanup_result?; + assert_forbidden( + list_result, "a member must not list another user's EC2 credentials", ); - - a.cleanup().await?; - b.cleanup().await?; Ok(()) } @@ -252,8 +254,7 @@ async fn test_ec2_credential_delete_forbidden_cross_user() -> Result<()> { // The credential must have survived; admin cleans up. delete_ec2_credential(&admin, &b.user.id, &cred.access).await?; - a.cleanup().await?; - b.cleanup().await?; + cleanup_project_scoped_users([a, b]).await?; Ok(()) } diff --git a/tests/api/tests/api_v3/identity/user_groups.rs b/tests/api/tests/api_v3/identity/user_groups.rs index 9d5616ad3..3c193605a 100644 --- a/tests/api/tests/api_v3/identity/user_groups.rs +++ b/tests/api/tests/api_v3/identity/user_groups.rs @@ -32,7 +32,9 @@ use openstack_sdk::{AsyncOpenStack, config::CloudConfig}; use test_api::asserts::{assert_forbidden, assert_unauthorized}; use test_api::common::raw_request; -use test_api::fixtures::{ProjectScopedUser, warn_on_cleanup_failure}; +use test_api::fixtures::{ + ProjectScopedUser, cleanup_project_scoped_users, warn_on_cleanup_failure, +}; use test_api::identity::user::{UserGroupsRequest, list_user_groups}; async fn admin_session() -> Result> { @@ -68,13 +70,14 @@ async fn test_user_groups_forbidden_project_scoped_user() -> Result<()> { } }; + let list_result = list_user_groups(&a.session, &b.user.id, UserGroupsRequest::default()).await; + let cleanup_result = cleanup_project_scoped_users([a, b]).await; + + cleanup_result?; assert_forbidden( - list_user_groups(&a.session, &b.user.id, UserGroupsRequest::default()).await, + list_result, "a project-scoped user must not read another user's group memberships", ); - - a.cleanup().await?; - b.cleanup().await?; Ok(()) } diff --git a/tests/api/tests/api_v3/role/imply/list_inferences.rs b/tests/api/tests/api_v3/role/imply/list_inferences.rs index f29c20348..4d52d5647 100644 --- a/tests/api/tests/api_v3/role/imply/list_inferences.rs +++ b/tests/api/tests/api_v3/role/imply/list_inferences.rs @@ -65,12 +65,14 @@ async fn test_list_role_inferences_forbidden_project_scoped_member() -> Result<( let admin = Arc::new(AsyncOpenStack::new(&CloudConfig::from_env()?).await?); let member = ProjectScopedUser::provision(&admin, "default", "member").await?; + let list_result = list_role_inferences(&member.session).await; + let cleanup_result = member.cleanup().await; + + cleanup_result?; assert_forbidden( - list_role_inferences(&member.session).await, + list_result, "a project-scoped member must not list global role inferences", ); - - member.cleanup().await?; Ok(()) } diff --git a/tools/start-api.sh b/tools/start-api.sh index 990240b1f..f968c3b9f 100755 --- a/tools/start-api.sh +++ b/tools/start-api.sh @@ -68,6 +68,9 @@ opa_policies_path = policy # into a token as a bitmask over exactly this list # (crates/token-driver-fernet/src/lib.rs), so both must be present here or # token issuance 500s with "unsupported authentication methods". +# ec2credential is the method carried by tokens minted at POST /v3/ec2tokens +# (exercised by the test_api EC2 suite) and needs a bitmask slot just the +# same. methods = password,token,openid,application_credential,x509,mapped,hacked_appcred_handler,ec2credential [DEFAULT] From 4a439588c3ec6a046435e82a5024216ea004bb57 Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Thu, 30 Jul 2026 19:04:44 +0000 Subject: [PATCH 9/9] test(api-types): Gate validator-only user tests Compile FederationProtocol validation tests only when the validate feature provides the Validate implementation. This restores the default crate test configuration after rebasing onto upstream/main. Signed-off-by: ShJ-code --- crates/api-types/src/v3/user.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/api-types/src/v3/user.rs b/crates/api-types/src/v3/user.rs index e9def33db..43d3859f1 100644 --- a/crates/api-types/src/v3/user.rs +++ b/crates/api-types/src/v3/user.rs @@ -673,6 +673,7 @@ mod tests { //); } + #[cfg(feature = "validate")] #[test] fn federation_protocol_accepts_long_external_unique_id() { let long_unique_id = @@ -686,6 +687,7 @@ mod tests { assert!(protocol.validate().is_ok()); } + #[cfg(feature = "validate")] #[test] fn federation_protocol_rejects_overlong_unique_id() { let protocol = FederationProtocol {