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/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 { 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/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/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, 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/README.md b/tests/api/README.md index f191407db..51a3674ca 100644 --- a/tests/api/README.md +++ b/tests/api/README.md @@ -46,22 +46,64 @@ 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?; + +// 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 +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. 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`) + +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/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/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/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/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/fixtures.rs b/tests/api/src/fixtures.rs new file mode 100644 index 000000000..255fbe565 --- /dev/null +++ b/tests/api/src/fixtures.rs @@ -0,0 +1,210 @@ +// 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, 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, add_system_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<()> { + 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(()), + } +} + +/// 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/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/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/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/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..abd01e361 --- /dev/null +++ b/tests/api/tests/api_v3/auth/ec2tokens.rs @@ -0,0 +1,331 @@ +// 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` | +//! | 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 +//! 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_status, assert_unauthorized}; +use test_api::auth::ec2::{ec2_token_request_body, post_ec2_token, post_ec2_token_extract}; +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}; + +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<()> { + 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] +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_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() + .map(|response| response.status()), + "member callers must be denied by policy", + ); + 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 response_result = post_ec2_token(Some(&admin_token().await?), body).await; + let cleanup_result = 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(()) +} + +#[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 response_result = post_ec2_token(Some(&admin_token().await?), body).await; + let cleanup_result = 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(()) +} + +/// 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_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?; + 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..75d331e70 --- /dev/null +++ b/tests/api/tests/api_v3/credential/ec2.rs @@ -0,0 +1,272 @@ +// 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, cleanup_project_scoped_users, 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", + ); + + cleanup_project_scoped_users([a, b]).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?; + cleanup_project_scoped_users([a, b]).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?; + + let list_result = + list_ec2_credentials(&a.session, &b.user.id, Ec2CredentialListRequest::default()) + .await + .map(|creds| { + creds + .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", + ); + 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?; + cleanup_project_scoped_users([a, b]).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(()) +} 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/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(()) } 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..3c193605a --- /dev/null +++ b/tests/api/tests/api_v3/identity/user_groups.rs @@ -0,0 +1,95 @@ +// 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, cleanup_project_scoped_users, 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); + } + }; + + 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_result, + "a project-scoped user must not read another user's group memberships", + ); + 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/api_v3/role/imply/list_inferences.rs b/tests/api/tests/api_v3/role/imply/list_inferences.rs index b25c03745..4d52d5647 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,49 @@ 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?; + + let list_result = list_role_inferences(&member.session).await; + let cleanup_result = member.cleanup().await; + + cleanup_result?; + assert_forbidden( + list_result, + "a project-scoped member must not list global role inferences", + ); + 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(()) +} 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(()) +} 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..f968c3b9f 100755 --- a/tools/start-api.sh +++ b/tools/start-api.sh @@ -68,7 +68,10 @@ 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 +# 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] use_stderr = false