diff --git a/.github/workflows/openstack_keystone.yml b/.github/workflows/openstack_keystone.yml new file mode 100644 index 00000000..7f2ac629 --- /dev/null +++ b/.github/workflows/openstack_keystone.yml @@ -0,0 +1,124 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: OpenStack Keystone Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +env: + RUST_LOG: DEBUG + RUST_BACKTRACE: full + +jobs: + # Unit tests - always run, no secrets needed + unit_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run unit tests + working-directory: ./services/openstack-keystone + run: | + echo "::group::Running unit tests" + cargo test --lib --no-fail-fast + cargo test --doc --no-fail-fast + echo "::endgroup::" + + # KeystoneCredentialProvider test with mock server + test_keystone_mock: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Start mock Keystone server + run: | + python3 services/openstack-keystone/tests/mocks/keystone_mock_server.py 5000 & + echo "MOCK_PID=$!" >> $GITHUB_ENV + sleep 2 + # Verify the mock server is running + curl -f -X POST http://127.0.0.1:5000/v3/auth/tokens \ + -H "Content-Type: application/json" \ + -d '{"auth":{"identity":{"methods":["password"],"password":{"user":{"name":"test","password":"test","domain":{"name":"Default"}}}}}}' \ + -o /dev/null -w "HTTP %{http_code}\n" || exit 1 + - name: Test KeystoneCredentialProvider with mock + working-directory: ./services/openstack-keystone + run: | + echo "::group::Testing KeystoneCredentialProvider with mock" + export REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK=on + export REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL=http://127.0.0.1:5000/v3 + cargo test --test credential_providers --no-fail-fast -- --no-capture + echo "::endgroup::" + - name: Stop mock server + if: always() + run: | + if [ ! -z "$MOCK_PID" ]; then + kill $MOCK_PID || true + fi + + # Summary + test_summary: + needs: + [ + unit_test, + test_keystone_mock, + ] + if: always() + runs-on: ubuntu-latest + steps: + - name: Test Summary + run: | + echo "## OpenStack Keystone Test Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Unit tests status + if [[ "${{ needs.unit_test.result }}" == "success" ]]; then + echo "✅ Unit tests passed" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ needs.unit_test.result }}" == "skipped" ]]; then + echo "⏭️ Unit tests skipped" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Unit tests failed" >> $GITHUB_STEP_SUMMARY + fi + + # Mock tests status + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Integration Tests" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ needs.test_keystone_mock.result }}" == "success" ]]; then + echo "✅ Keystone mock tests passed" >> $GITHUB_STEP_SUMMARY + elif [[ "${{ needs.test_keystone_mock.result }}" == "skipped" ]]; then + echo "⏭️ Keystone mock tests skipped" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Keystone mock tests failed" >> $GITHUB_STEP_SUMMARY + fi + + # Overall status + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "${{ needs.unit_test.result }}" == "success" ]] && \ + ( [[ "${{ needs.test_keystone_mock.result }}" == "success" ]] || [[ "${{ needs.test_keystone_mock.result }}" == "skipped" ]] ); then + echo "### ✅ All tests completed successfully" >> $GITHUB_STEP_SUMMARY + else + echo "### ❌ Some tests failed" >> $GITHUB_STEP_SUMMARY + exit 1 + fi diff --git a/Cargo.toml b/Cargo.toml index 52e578f2..b3cd3f40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ reqsign-file-read-tokio = { version = "2.0.2", path = "context/file-read-tokio" reqsign-google = { version = "2.0.2", path = "services/google" } reqsign-http-send-reqwest = { version = "3.0.0", path = "context/http-send-reqwest" } reqsign-huaweicloud-obs = { version = "2.0.2", path = "services/huaweicloud-obs" } +reqsign-openstack-keystone = { version = "2.0.0", path = "services/openstack-keystone" } reqsign-oracle = { version = "2.0.2", path = "services/oracle" } reqsign-tencent-cos = { version = "2.0.2", path = "services/tencent-cos" } diff --git a/reqsign/Cargo.toml b/reqsign/Cargo.toml index 843c78a2..0590a014 100644 --- a/reqsign/Cargo.toml +++ b/reqsign/Cargo.toml @@ -42,6 +42,7 @@ reqsign-aws-v4 = { workspace = true, optional = true } reqsign-azure-storage = { workspace = true, optional = true } reqsign-google = { workspace = true, optional = true } reqsign-huaweicloud-obs = { workspace = true, optional = true } +reqsign-openstack-keystone = { workspace = true, optional = true } reqsign-oracle = { workspace = true, optional = true } reqsign-tencent-cos = { workspace = true, optional = true } @@ -64,11 +65,12 @@ aws = ["dep:reqsign-aws-v4"] azure = ["dep:reqsign-azure-storage"] google = ["dep:reqsign-google"] huaweicloud = ["dep:reqsign-huaweicloud-obs"] +openstack = ["dep:reqsign-openstack-keystone"] oracle = ["dep:reqsign-oracle"] tencent = ["dep:reqsign-tencent-cos"] # Full feature set -full = ["aliyun", "aws", "azure", "google", "huaweicloud", "oracle", "tencent"] +full = ["aliyun", "aws", "azure", "google", "huaweicloud", "openstack", "oracle", "tencent"] [dev-dependencies] anyhow = "1" diff --git a/reqsign/src/lib.rs b/reqsign/src/lib.rs index 9184937d..879a081c 100644 --- a/reqsign/src/lib.rs +++ b/reqsign/src/lib.rs @@ -43,6 +43,9 @@ pub mod google; #[cfg(feature = "huaweicloud")] pub mod huaweicloud; +#[cfg(feature = "openstack")] +pub mod openstack; + #[cfg(feature = "oracle")] pub mod oracle; diff --git a/reqsign/src/openstack.rs b/reqsign/src/openstack.rs new file mode 100644 index 00000000..d0f314f0 --- /dev/null +++ b/reqsign/src/openstack.rs @@ -0,0 +1,65 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +//! OpenStack Keystone service support with convenience APIs +//! +//! This module provides OpenStack Keystone authentication functionality along +//! with convenience functions for common use cases. + +// Re-export all OpenStack Keystone types +pub use reqsign_openstack_keystone::*; + +#[cfg(feature = "default-context")] +use crate::{Signer, default_context}; + +/// Default OpenStack Signer type with commonly used components +#[cfg(feature = "default-context")] +pub type DefaultSigner = Signer; + +/// Create a default OpenStack signer with standard configuration +/// +/// This function creates a signer with: +/// - Default context (with Tokio file reader, reqwest HTTP client, OS environment) +/// - Default credential provider (reads from `OPENSTACK_*` env vars) +/// - Request signer that inserts `X-Auth-Token` header +/// +/// # Example +/// +/// ```no_run +/// # #[tokio::main] +/// # async fn main() -> reqsign_core::Result<()> { +/// let signer = reqsign::openstack::default_signer(); +/// +/// let mut req = http::Request::builder() +/// .method("GET") +/// .uri("https://swift.example.com/v1/AUTH_test/container/object") +/// .body(()) +/// .unwrap() +/// .into_parts() +/// .0; +/// +/// signer.sign(&mut req, None).await?; +/// # Ok(()) +/// # } +/// ``` +#[cfg(feature = "default-context")] +pub fn default_signer() -> DefaultSigner { + let ctx = default_context(); + let provider = DefaultCredentialProvider::new(); + let signer = RequestSigner; + Signer::new(ctx, provider, signer) +} diff --git a/services/openstack-keystone/Cargo.toml b/services/openstack-keystone/Cargo.toml new file mode 100644 index 00000000..b13b61bd --- /dev/null +++ b/services/openstack-keystone/Cargo.toml @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +[package] +name = "reqsign-openstack-keystone" +version = "2.0.0" + +description = "OpenStack Keystone authentication implementation for reqsign." + +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +async-trait = { workspace = true } +http = { workspace = true } +log = { workspace = true } +reqsign-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +dotenvy = { workspace = true } +env_logger = { workspace = true } +reqsign-file-read-tokio = { workspace = true } +reqsign-http-send-reqwest = { workspace = true } +reqwest = { workspace = true, features = ["default-tls"] } +tokio = { workspace = true, features = ["full"] } diff --git a/services/openstack-keystone/src/credential.rs b/services/openstack-keystone/src/credential.rs new file mode 100644 index 00000000..41b1a5b1 --- /dev/null +++ b/services/openstack-keystone/src/credential.rs @@ -0,0 +1,473 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +use reqsign_core::{SigningCredential, time::Timestamp, utils::Redact}; +use std::fmt::{self, Debug}; +use std::time::Duration; + +/// Credential represents an OpenStack Keystone authentication token +/// with an optional service catalog for endpoint discovery. +#[derive(Clone, Default)] +pub struct Credential { + /// The X-Auth-Token value. + pub token: String, + /// The expiration time of the token. + pub expires_at: Option, + /// The service catalog returned by Keystone. + pub service_catalog: Vec, +} + +impl Debug for Credential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credential") + .field("token", &Redact::from(&self.token)) + .field("expires_at", &self.expires_at) + .field("service_catalog", &self.service_catalog) + .finish() + } +} + +impl SigningCredential for Credential { + fn is_valid(&self) -> bool { + if self.token.is_empty() { + return false; + } + + match self.expires_at { + Some(expires_at) => { + // Consider token invalid if it expires within 2 minutes + let buffer = Duration::from_secs(120); + Timestamp::now() < expires_at - buffer + } + None => true, + } + } +} + +impl Credential { + /// Look up an endpoint URL from the service catalog. + /// + /// Searches for a service matching `service_type` and returns the URL + /// of the first endpoint matching `interface` (e.g. "public", "internal", "admin"). + pub fn endpoint(&self, service_type: &str, interface: &str) -> Option<&str> { + self.service_catalog + .iter() + .find(|entry| entry.service_type == service_type) + .and_then(|entry| { + entry + .endpoints + .iter() + .find(|ep| ep.interface == interface) + .map(|ep| ep.url.as_str()) + }) + } + + /// Look up an endpoint URL from the service catalog, filtered by region. + /// + /// Like [`endpoint()`](Self::endpoint), but only matches endpoints in the + /// specified region. + pub fn endpoint_in_region( + &self, + service_type: &str, + interface: &str, + region: &str, + ) -> Option<&str> { + self.service_catalog + .iter() + .find(|entry| entry.service_type == service_type) + .and_then(|entry| { + entry + .endpoints + .iter() + .find(|ep| ep.interface == interface && ep.region.as_deref() == Some(region)) + .map(|ep| ep.url.as_str()) + }) + } +} + +/// A service entry from the Keystone service catalog. +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub struct CatalogEntry { + /// The service type (e.g. "object-store", "compute", "identity"). + #[serde(rename = "type")] + pub service_type: String, + /// The list of endpoints for this service. + pub endpoints: Vec, +} + +/// A single endpoint within a catalog entry. +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub struct Endpoint { + /// The interface type (e.g. "public", "internal", "admin"). + pub interface: String, + /// The endpoint URL. + pub url: String, + /// The region identifier. + #[serde(default)] + pub region: Option, + /// The region ID. + #[serde(default)] + pub region_id: Option, +} + +/// Keystone v3 authentication request/response types. +pub(crate) mod keystone_v3 { + use serde::{Deserialize, Serialize}; + + /// Top-level authentication request body. + #[derive(Serialize)] + pub(crate) struct AuthRequest { + pub(crate) auth: Auth, + } + + /// The auth block with identity and optional scope. + #[derive(Serialize)] + pub(crate) struct Auth { + pub(crate) identity: Identity, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) scope: Option, + } + + /// Identity section — only password method is supported. + #[derive(Serialize)] + pub(crate) struct Identity { + pub(crate) methods: Vec, + pub(crate) password: Password, + } + + /// Password credentials. + #[derive(Serialize)] + pub(crate) struct Password { + pub(crate) user: User, + } + + /// User credentials with domain. + #[derive(Serialize)] + pub(crate) struct User { + pub(crate) name: String, + pub(crate) password: String, + pub(crate) domain: Domain, + } + + /// Domain identifier. + #[derive(Serialize)] + pub(crate) struct Domain { + pub(crate) name: String, + } + + /// Scope for the token (project-scoped). + #[derive(Serialize)] + pub(crate) struct Scope { + pub(crate) project: Project, + } + + /// Project with domain. + #[derive(Serialize)] + pub(crate) struct Project { + pub(crate) name: String, + pub(crate) domain: Domain, + } + + /// The top-level token response from Keystone. + #[derive(Deserialize)] + pub(crate) struct TokenResponse { + pub(crate) token: TokenBody, + } + + /// The token body containing expiry and catalog. + #[derive(Deserialize)] + pub(crate) struct TokenBody { + pub(crate) expires_at: String, + #[serde(default)] + pub(crate) catalog: Vec, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_credential_is_valid_empty_token() { + let cred = Credential::default(); + assert!(!cred.is_valid()); + } + + #[test] + fn test_credential_is_valid_no_expiry() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: None, + service_catalog: vec![], + }; + assert!(cred.is_valid()); + } + + #[test] + fn test_credential_is_valid_future_expiry() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: Some(Timestamp::now() + Duration::from_secs(3600)), + service_catalog: vec![], + }; + assert!(cred.is_valid()); + } + + #[test] + fn test_credential_is_valid_expires_within_grace() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: Some(Timestamp::now() + Duration::from_secs(30)), + service_catalog: vec![], + }; + assert!(!cred.is_valid()); + } + + #[test] + fn test_credential_is_valid_expired() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: Some(Timestamp::now() - Duration::from_secs(3600)), + service_catalog: vec![], + }; + assert!(!cred.is_valid()); + } + + #[test] + fn test_credential_endpoint_lookup() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: None, + service_catalog: vec![ + CatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![ + Endpoint { + interface: "public".to_string(), + url: "https://swift.example.com/v1/AUTH_test".to_string(), + region: Some("RegionOne".to_string()), + region_id: None, + }, + Endpoint { + interface: "internal".to_string(), + url: "http://swift-internal:8080/v1/AUTH_test".to_string(), + region: Some("RegionOne".to_string()), + region_id: None, + }, + ], + }, + CatalogEntry { + service_type: "identity".to_string(), + endpoints: vec![Endpoint { + interface: "public".to_string(), + url: "https://keystone.example.com/v3".to_string(), + region: Some("RegionOne".to_string()), + region_id: None, + }], + }, + ], + }; + + assert_eq!( + cred.endpoint("object-store", "public"), + Some("https://swift.example.com/v1/AUTH_test") + ); + assert_eq!( + cred.endpoint("object-store", "internal"), + Some("http://swift-internal:8080/v1/AUTH_test") + ); + assert_eq!( + cred.endpoint("identity", "public"), + Some("https://keystone.example.com/v3") + ); + assert_eq!(cred.endpoint("compute", "public"), None); + assert_eq!(cred.endpoint("object-store", "admin"), None); + } + + #[test] + fn test_catalog_entry_deserialize() { + let json = r#"{ + "type": "object-store", + "endpoints": [ + { + "interface": "public", + "url": "https://swift.example.com/v1/AUTH_test", + "region": "RegionOne", + "region_id": "RegionOne" + } + ] + }"#; + + let entry: CatalogEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.service_type, "object-store"); + assert_eq!(entry.endpoints.len(), 1); + assert_eq!(entry.endpoints[0].interface, "public"); + assert_eq!( + entry.endpoints[0].url, + "https://swift.example.com/v1/AUTH_test" + ); + } + + #[test] + fn test_credential_endpoint_in_region() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: None, + service_catalog: vec![CatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![ + Endpoint { + interface: "public".to_string(), + url: "https://swift-us.example.com/v1/AUTH_test".to_string(), + region: Some("us-east-1".to_string()), + region_id: None, + }, + Endpoint { + interface: "public".to_string(), + url: "https://swift-eu.example.com/v1/AUTH_test".to_string(), + region: Some("eu-west-1".to_string()), + region_id: None, + }, + Endpoint { + interface: "internal".to_string(), + url: "http://swift-internal-us:8080/v1/AUTH_test".to_string(), + region: Some("us-east-1".to_string()), + region_id: None, + }, + ], + }], + }; + + // Match region + interface + assert_eq!( + cred.endpoint_in_region("object-store", "public", "us-east-1"), + Some("https://swift-us.example.com/v1/AUTH_test") + ); + assert_eq!( + cred.endpoint_in_region("object-store", "public", "eu-west-1"), + Some("https://swift-eu.example.com/v1/AUTH_test") + ); + assert_eq!( + cred.endpoint_in_region("object-store", "internal", "us-east-1"), + Some("http://swift-internal-us:8080/v1/AUTH_test") + ); + + // Wrong region + assert_eq!( + cred.endpoint_in_region("object-store", "public", "ap-southeast-1"), + None + ); + // Wrong interface in region + assert_eq!( + cred.endpoint_in_region("object-store", "admin", "us-east-1"), + None + ); + // Wrong service type + assert_eq!( + cred.endpoint_in_region("compute", "public", "us-east-1"), + None + ); + } + + #[test] + fn test_credential_endpoint_empty_catalog() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: None, + service_catalog: vec![], + }; + + assert_eq!(cred.endpoint("object-store", "public"), None); + assert_eq!( + cred.endpoint_in_region("object-store", "public", "RegionOne"), + None + ); + } + + #[test] + fn test_credential_endpoint_service_with_no_endpoints() { + let cred = Credential { + token: "test-token".to_string(), + expires_at: None, + service_catalog: vec![CatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![], + }], + }; + + assert_eq!(cred.endpoint("object-store", "public"), None); + assert_eq!( + cred.endpoint_in_region("object-store", "public", "RegionOne"), + None + ); + } + + #[test] + fn test_credential_endpoint_no_region_field() { + // When endpoint has no region, region-aware lookup should not match + let cred = Credential { + token: "test-token".to_string(), + expires_at: None, + service_catalog: vec![CatalogEntry { + service_type: "object-store".to_string(), + endpoints: vec![Endpoint { + interface: "public".to_string(), + url: "https://swift.example.com/v1/AUTH_test".to_string(), + region: None, + region_id: None, + }], + }], + }; + + // Non-region lookup should still find it + assert_eq!( + cred.endpoint("object-store", "public"), + Some("https://swift.example.com/v1/AUTH_test") + ); + // Region-aware lookup should not match + assert_eq!( + cred.endpoint_in_region("object-store", "public", "RegionOne"), + None + ); + } + + #[test] + fn test_keystone_v3_token_response_deserialize() { + let json = r#"{ + "token": { + "expires_at": "2025-01-15T12:00:00.000000Z", + "catalog": [ + { + "type": "object-store", + "endpoints": [ + { + "interface": "public", + "url": "https://swift.example.com/v1/AUTH_test", + "region": "RegionOne" + } + ] + } + ] + } + }"#; + + let resp: keystone_v3::TokenResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.token.expires_at, "2025-01-15T12:00:00.000000Z"); + assert_eq!(resp.token.catalog.len(), 1); + assert_eq!(resp.token.catalog[0].service_type, "object-store"); + } +} diff --git a/services/openstack-keystone/src/lib.rs b/services/openstack-keystone/src/lib.rs new file mode 100644 index 00000000..432789fc --- /dev/null +++ b/services/openstack-keystone/src/lib.rs @@ -0,0 +1,29 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +//! OpenStack Keystone Authentication + +mod credential; +pub use credential::{CatalogEntry, Credential, Endpoint}; + +mod sign_request; +pub use sign_request::RequestSigner; + +mod provide_credential; +pub use provide_credential::{ + DefaultCredentialProvider, EnvCredentialProvider, KeystoneCredentialProvider, +}; diff --git a/services/openstack-keystone/src/provide_credential/default.rs b/services/openstack-keystone/src/provide_credential/default.rs new file mode 100644 index 00000000..7d6d99e1 --- /dev/null +++ b/services/openstack-keystone/src/provide_credential/default.rs @@ -0,0 +1,68 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result}; + +use crate::credential::Credential; + +use super::env::EnvCredentialProvider; + +/// Default credential provider for OpenStack Keystone. +/// +/// Tries credential sources in order: +/// 1. Environment variables (`OPENSTACK_AUTH_URL`, `OPENSTACK_USERNAME`, etc.) +#[derive(Debug)] +pub struct DefaultCredentialProvider { + chain: ProvideCredentialChain, +} + +impl Default for DefaultCredentialProvider { + fn default() -> Self { + Self::new() + } +} + +impl DefaultCredentialProvider { + /// Create a new DefaultCredentialProvider with the default chain. + pub fn new() -> Self { + let chain = ProvideCredentialChain::new().push(EnvCredentialProvider::new()); + Self { chain } + } + + /// Create with a custom credential chain. + pub fn with_chain(chain: ProvideCredentialChain) -> Self { + Self { chain } + } + + /// Add a credential provider to the front of the chain. + pub fn push_front( + mut self, + provider: impl ProvideCredential + 'static, + ) -> Self { + self.chain = self.chain.push_front(provider); + self + } +} + +#[async_trait::async_trait] +impl ProvideCredential for DefaultCredentialProvider { + type Credential = Credential; + + async fn provide_credential(&self, ctx: &Context) -> Result> { + self.chain.provide_credential(ctx).await + } +} diff --git a/services/openstack-keystone/src/provide_credential/env.rs b/services/openstack-keystone/src/provide_credential/env.rs new file mode 100644 index 00000000..cc66ef25 --- /dev/null +++ b/services/openstack-keystone/src/provide_credential/env.rs @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +use log::debug; + +use reqsign_core::{Context, ProvideCredential, Result}; + +use crate::credential::Credential; + +use super::keystone::KeystoneCredentialProvider; + +/// Environment variable names for OpenStack credentials. +const OPENSTACK_AUTH_URL: &str = "OPENSTACK_AUTH_URL"; +const OPENSTACK_USERNAME: &str = "OPENSTACK_USERNAME"; +const OPENSTACK_PASSWORD: &str = "OPENSTACK_PASSWORD"; +const OPENSTACK_DOMAIN_NAME: &str = "OPENSTACK_DOMAIN_NAME"; +const OPENSTACK_PROJECT_NAME: &str = "OPENSTACK_PROJECT_NAME"; +const OPENSTACK_PROJECT_DOMAIN_NAME: &str = "OPENSTACK_PROJECT_DOMAIN_NAME"; + +/// Credential provider that reads OpenStack credentials from environment variables +/// and authenticates via Keystone v3. +/// +/// Required environment variables: +/// - `OPENSTACK_AUTH_URL` — Keystone v3 identity URL (e.g. `https://keystone.example.com/v3`) +/// - `OPENSTACK_USERNAME` — OpenStack username +/// - `OPENSTACK_PASSWORD` — OpenStack password +/// +/// Optional environment variables: +/// - `OPENSTACK_DOMAIN_NAME` — User domain name (defaults to "Default") +/// - `OPENSTACK_PROJECT_NAME` — Project name for scoped tokens +/// - `OPENSTACK_PROJECT_DOMAIN_NAME` — Project domain name (defaults to user domain) +#[derive(Debug, Default)] +pub struct EnvCredentialProvider; + +impl EnvCredentialProvider { + /// Create a new EnvCredentialProvider. + pub fn new() -> Self { + Self + } +} + +#[async_trait::async_trait] +impl ProvideCredential for EnvCredentialProvider { + type Credential = Credential; + + async fn provide_credential(&self, ctx: &Context) -> Result> { + let auth_url = match ctx.env_var(OPENSTACK_AUTH_URL) { + Some(v) if !v.is_empty() => v, + _ => { + debug!("{OPENSTACK_AUTH_URL} not set, skipping env credential provider"); + return Ok(None); + } + }; + + let username = match ctx.env_var(OPENSTACK_USERNAME) { + Some(v) if !v.is_empty() => v, + _ => { + debug!("{OPENSTACK_USERNAME} not set, skipping env credential provider"); + return Ok(None); + } + }; + + let password = match ctx.env_var(OPENSTACK_PASSWORD) { + Some(v) if !v.is_empty() => v, + _ => { + debug!("{OPENSTACK_PASSWORD} not set, skipping env credential provider"); + return Ok(None); + } + }; + + let domain_name = ctx + .env_var(OPENSTACK_DOMAIN_NAME) + .unwrap_or_else(|| "Default".to_string()); + + let project_domain_name = ctx + .env_var(OPENSTACK_PROJECT_DOMAIN_NAME) + .unwrap_or_else(|| domain_name.clone()); + + debug!("loaded OpenStack credentials from environment for user: {username}"); + + let mut provider = KeystoneCredentialProvider::new(&auth_url) + .with_username(&username) + .with_password(&password) + .with_user_domain_name(&domain_name); + + if let Some(project_name) = ctx.env_var(OPENSTACK_PROJECT_NAME) { + if !project_name.is_empty() { + provider = provider + .with_project_name(&project_name) + .with_project_domain_name(&project_domain_name); + } + } + + provider.provide_credential(ctx).await + } +} diff --git a/services/openstack-keystone/src/provide_credential/keystone.rs b/services/openstack-keystone/src/provide_credential/keystone.rs new file mode 100644 index 00000000..099e576d --- /dev/null +++ b/services/openstack-keystone/src/provide_credential/keystone.rs @@ -0,0 +1,250 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +use http::header; +use log::debug; + +use reqsign_core::{Context, ProvideCredential, Result, time::Timestamp}; + +use crate::credential::Credential; +use crate::credential::keystone_v3; + +/// Credential provider that authenticates against a Keystone v3 identity service. +/// +/// This provider performs a POST to `{auth_url}/auth/tokens` with password credentials +/// and extracts the token from the `X-Subject-Token` response header. +#[derive(Debug, Clone)] +pub struct KeystoneCredentialProvider { + auth_url: String, + username: String, + password: String, + user_domain_name: String, + project_name: Option, + project_domain_name: Option, +} + +impl KeystoneCredentialProvider { + /// Create a new KeystoneCredentialProvider with the Keystone identity URL. + /// + /// The `auth_url` should be the base Keystone v3 URL, e.g. `https://keystone.example.com/v3`. + pub fn new(auth_url: impl Into) -> Self { + Self { + auth_url: auth_url.into(), + username: String::new(), + password: String::new(), + user_domain_name: "Default".to_string(), + project_name: None, + project_domain_name: None, + } + } + + /// Set the username. + pub fn with_username(mut self, username: impl Into) -> Self { + self.username = username.into(); + self + } + + /// Set the password. + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = password.into(); + self + } + + /// Set the user domain name. + pub fn with_user_domain_name(mut self, domain_name: impl Into) -> Self { + self.user_domain_name = domain_name.into(); + self + } + + /// Set the project name for scoped tokens. + pub fn with_project_name(mut self, project_name: impl Into) -> Self { + self.project_name = Some(project_name.into()); + self + } + + /// Set the project domain name. + pub fn with_project_domain_name(mut self, domain_name: impl Into) -> Self { + self.project_domain_name = Some(domain_name.into()); + self + } + + fn build_auth_request(&self) -> keystone_v3::AuthRequest { + let scope = self + .project_name + .as_ref() + .map(|project_name| keystone_v3::Scope { + project: keystone_v3::Project { + name: project_name.clone(), + domain: keystone_v3::Domain { + name: self + .project_domain_name + .clone() + .unwrap_or_else(|| self.user_domain_name.clone()), + }, + }, + }); + + keystone_v3::AuthRequest { + auth: keystone_v3::Auth { + identity: keystone_v3::Identity { + methods: vec!["password".to_string()], + password: keystone_v3::Password { + user: keystone_v3::User { + name: self.username.clone(), + password: self.password.clone(), + domain: keystone_v3::Domain { + name: self.user_domain_name.clone(), + }, + }, + }, + }, + scope, + }, + } + } +} + +#[async_trait::async_trait] +impl ProvideCredential for KeystoneCredentialProvider { + type Credential = Credential; + + async fn provide_credential(&self, ctx: &Context) -> Result> { + if self.username.is_empty() || self.password.is_empty() { + debug!("username or password not set, skipping keystone credential provider"); + return Ok(None); + } + + let auth_request = self.build_auth_request(); + let body = serde_json::to_vec(&auth_request).map_err(|e| { + reqsign_core::Error::unexpected("failed to serialize auth request").with_source(e) + })?; + + let url = format!("{}/auth/tokens", self.auth_url.trim_end_matches('/')); + + debug!( + "authenticating with Keystone at {url} as user {}", + self.username + ); + + let req = http::Request::builder() + .method(http::Method::POST) + .uri(&url) + .header(header::CONTENT_TYPE, "application/json") + .body(body.into()) + .map_err(|e| { + reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e) + })?; + + let resp = ctx.http_send(req).await?; + + if resp.status() != http::StatusCode::CREATED { + let body = String::from_utf8_lossy(resp.body()); + return Err(reqsign_core::Error::unexpected(format!( + "Keystone authentication failed (status {}): {body}", + resp.status() + ))); + } + + let token = resp + .headers() + .get("x-subject-token") + .ok_or_else(|| { + reqsign_core::Error::unexpected("Keystone response missing X-Subject-Token header") + })? + .to_str() + .map_err(|e| { + reqsign_core::Error::unexpected("X-Subject-Token header is not valid UTF-8") + .with_source(e) + })? + .to_string(); + + let token_response: keystone_v3::TokenResponse = serde_json::from_slice(resp.body()) + .map_err(|e| { + reqsign_core::Error::unexpected("failed to parse Keystone token response") + .with_source(e) + })?; + + let expires_at: Timestamp = token_response.token.expires_at.parse().map_err(|e| { + reqsign_core::Error::unexpected(format!( + "failed to parse Keystone token expiry '{}' as timestamp", + token_response.token.expires_at + )) + .with_source(e) + })?; + + debug!( + "Keystone authentication successful, token expires at {expires_at:?}, catalog has {} services", + token_response.token.catalog.len() + ); + + Ok(Some(Credential { + token, + expires_at: Some(expires_at), + service_catalog: token_response.token.catalog, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_auth_request_unscoped() { + let provider = KeystoneCredentialProvider::new("https://keystone.example.com/v3") + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default"); + + let req = provider.build_auth_request(); + let json = serde_json::to_value(&req).unwrap(); + + assert_eq!(json["auth"]["identity"]["methods"][0], "password"); + assert_eq!( + json["auth"]["identity"]["password"]["user"]["name"], + "testuser" + ); + assert_eq!( + json["auth"]["identity"]["password"]["user"]["password"], + "testpass" + ); + assert_eq!( + json["auth"]["identity"]["password"]["user"]["domain"]["name"], + "Default" + ); + assert!(json["auth"]["scope"].is_null()); + } + + #[test] + fn test_build_auth_request_project_scoped() { + let provider = KeystoneCredentialProvider::new("https://keystone.example.com/v3") + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default") + .with_project_name("myproject") + .with_project_domain_name("Default"); + + let req = provider.build_auth_request(); + let json = serde_json::to_value(&req).unwrap(); + + assert_eq!(json["auth"]["scope"]["project"]["name"], "myproject"); + assert_eq!( + json["auth"]["scope"]["project"]["domain"]["name"], + "Default" + ); + } +} diff --git a/services/openstack-keystone/src/provide_credential/mod.rs b/services/openstack-keystone/src/provide_credential/mod.rs new file mode 100644 index 00000000..69f1eb4c --- /dev/null +++ b/services/openstack-keystone/src/provide_credential/mod.rs @@ -0,0 +1,25 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +mod default; +pub use default::DefaultCredentialProvider; + +mod env; +pub use env::EnvCredentialProvider; + +mod keystone; +pub use keystone::KeystoneCredentialProvider; diff --git a/services/openstack-keystone/src/sign_request.rs b/services/openstack-keystone/src/sign_request.rs new file mode 100644 index 00000000..add86e95 --- /dev/null +++ b/services/openstack-keystone/src/sign_request.rs @@ -0,0 +1,109 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +use std::time::Duration; + +use reqsign_core::{Context, Result, SignRequest}; + +use crate::credential::Credential; + +/// RequestSigner for OpenStack services. +/// +/// Signs requests by inserting the `X-Auth-Token` header with the +/// Keystone authentication token. +#[derive(Debug, Default)] +pub struct RequestSigner; + +#[async_trait::async_trait] +impl SignRequest for RequestSigner { + type Credential = Credential; + + async fn sign_request( + &self, + _ctx: &Context, + req: &mut http::request::Parts, + credential: Option<&Self::Credential>, + _expires_in: Option, + ) -> Result<()> { + let Some(cred) = credential else { + return Ok(()); + }; + + let mut value: http::HeaderValue = cred.token.parse().map_err(|e| { + reqsign_core::Error::unexpected("failed to parse token as header value").with_source(e) + })?; + value.set_sensitive(true); + + req.headers.insert("x-auth-token", value); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_sign_request_inserts_token() { + let signer = RequestSigner; + let ctx = Context::new(); + + let cred = Credential { + token: "test-token-123".to_string(), + expires_at: None, + service_catalog: vec![], + }; + + let req = http::Request::builder() + .method("GET") + .uri("https://swift.example.com/v1/AUTH_test/container/object") + .body(()) + .unwrap(); + let (mut parts, _body) = req.into_parts(); + + signer + .sign_request(&ctx, &mut parts, Some(&cred), None) + .await + .unwrap(); + + assert_eq!( + parts.headers.get("x-auth-token").unwrap().to_str().unwrap(), + "test-token-123" + ); + } + + #[tokio::test] + async fn test_sign_request_no_credential() { + let signer = RequestSigner; + let ctx = Context::new(); + + let req = http::Request::builder() + .method("GET") + .uri("https://swift.example.com/v1/AUTH_test/container/object") + .body(()) + .unwrap(); + let (mut parts, _body) = req.into_parts(); + + signer + .sign_request(&ctx, &mut parts, None, None) + .await + .unwrap(); + + assert!(parts.headers.get("x-auth-token").is_none()); + } +} diff --git a/services/openstack-keystone/tests/credential_providers.rs b/services/openstack-keystone/tests/credential_providers.rs new file mode 100644 index 00000000..aae9bad5 --- /dev/null +++ b/services/openstack-keystone/tests/credential_providers.rs @@ -0,0 +1,458 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +use std::env; + +use reqsign_core::{Context, OsEnv, ProvideCredential, Signer, SigningCredential, StaticEnv}; +use reqsign_openstack_keystone::{ + Credential, DefaultCredentialProvider, EnvCredentialProvider, KeystoneCredentialProvider, + RequestSigner, +}; +use std::collections::HashMap; + +fn default_context() -> Context { + Context::new() + .with_file_read(reqsign_file_read_tokio::TokioFileRead) + .with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default()) + .with_env(OsEnv) +} + +/// Test KeystoneCredentialProvider against a mock Keystone server. +/// +/// This test requires the mock server to be running: +/// ```bash +/// python3 tests/mocks/keystone_mock_server.py 5000 & +/// ``` +/// +/// And the following env var: +/// - REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK=on +#[tokio::test] +async fn test_keystone_credential_provider_with_mock() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let ctx = default_context(); + + let provider = KeystoneCredentialProvider::new(&mock_url) + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default") + .with_project_name("testproject") + .with_project_domain_name("Default"); + + let cred = provider + .provide_credential(&ctx) + .await + .expect("credential loading must succeed") + .expect("credential must be present"); + + assert!(!cred.token.is_empty(), "token must not be empty"); + assert!(cred.is_valid(), "credential must be valid"); + assert!(cred.expires_at.is_some(), "expires_at must be set"); + + // Verify service catalog + assert!( + !cred.service_catalog.is_empty(), + "service catalog must not be empty" + ); + + let swift_endpoint = cred.endpoint("object-store", "public"); + assert!( + swift_endpoint.is_some(), + "must find object-store public endpoint" + ); + assert!( + swift_endpoint.unwrap().contains("AUTH_test"), + "endpoint must contain AUTH_test" + ); +} + +/// Test KeystoneCredentialProvider rejects bad credentials. +#[tokio::test] +async fn test_keystone_credential_provider_bad_password() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let ctx = default_context(); + + let provider = KeystoneCredentialProvider::new(&mock_url) + .with_username("testuser") + .with_password("wrong-password") + .with_user_domain_name("Default"); + + let result = provider.provide_credential(&ctx).await; + assert!(result.is_err(), "bad password must fail"); +} + +/// Test EnvCredentialProvider with mock server. +#[tokio::test] +async fn test_env_credential_provider_with_mock() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let envs = HashMap::from([ + ("OPENSTACK_AUTH_URL".to_string(), mock_url), + ("OPENSTACK_USERNAME".to_string(), "testuser".to_string()), + ("OPENSTACK_PASSWORD".to_string(), "testpass".to_string()), + ("OPENSTACK_DOMAIN_NAME".to_string(), "Default".to_string()), + ( + "OPENSTACK_PROJECT_NAME".to_string(), + "testproject".to_string(), + ), + ]); + + let ctx = Context::new() + .with_file_read(reqsign_file_read_tokio::TokioFileRead) + .with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default()) + .with_env(StaticEnv { + home_dir: None, + envs, + }); + + let provider = EnvCredentialProvider::new(); + let cred = provider + .provide_credential(&ctx) + .await + .expect("credential loading must succeed") + .expect("credential must be present"); + + assert!(!cred.token.is_empty()); + assert!(cred.is_valid()); +} + +/// Test EnvCredentialProvider returns None when vars are missing. +#[tokio::test] +async fn test_env_credential_provider_missing_vars() { + let _ = env_logger::builder().is_test(true).try_init(); + + let envs = HashMap::new(); + let ctx = Context::new() + .with_file_read(reqsign_file_read_tokio::TokioFileRead) + .with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default()) + .with_env(StaticEnv { + home_dir: None, + envs, + }); + + let provider = EnvCredentialProvider::new(); + let result = provider + .provide_credential(&ctx) + .await + .expect("should not error"); + + assert!(result.is_none(), "must return None when vars are missing"); +} + +/// Test DefaultCredentialProvider with mock server. +#[tokio::test] +async fn test_default_credential_provider_with_mock() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let envs = HashMap::from([ + ("OPENSTACK_AUTH_URL".to_string(), mock_url), + ("OPENSTACK_USERNAME".to_string(), "testuser".to_string()), + ("OPENSTACK_PASSWORD".to_string(), "testpass".to_string()), + ("OPENSTACK_DOMAIN_NAME".to_string(), "Default".to_string()), + ( + "OPENSTACK_PROJECT_NAME".to_string(), + "testproject".to_string(), + ), + ]); + + let ctx = Context::new() + .with_file_read(reqsign_file_read_tokio::TokioFileRead) + .with_http_send(reqsign_http_send_reqwest::ReqwestHttpSend::default()) + .with_env(StaticEnv { + home_dir: None, + envs, + }); + + let provider = DefaultCredentialProvider::new(); + let cred = provider + .provide_credential(&ctx) + .await + .expect("credential loading must succeed") + .expect("credential must be present"); + + assert!(!cred.token.is_empty()); + assert!(cred.is_valid()); +} + +/// Test unscoped token (no project) — should get token but empty catalog. +#[tokio::test] +async fn test_keystone_credential_provider_unscoped() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let ctx = default_context(); + + // No project_name or project_domain_name — unscoped + let provider = KeystoneCredentialProvider::new(&mock_url) + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default"); + + let cred = provider + .provide_credential(&ctx) + .await + .expect("credential loading must succeed") + .expect("credential must be present"); + + assert!(!cred.token.is_empty(), "token must not be empty"); + assert!(cred.is_valid(), "credential must be valid"); + assert!(cred.expires_at.is_some(), "expires_at must be set"); + + // Unscoped tokens get no catalog + assert!( + cred.service_catalog.is_empty(), + "unscoped token must have empty service catalog" + ); + assert_eq!( + cred.endpoint("object-store", "public"), + None, + "unscoped token must not have endpoints" + ); +} + +/// Test full Signer round-trip: provider -> cache -> sign request. +#[tokio::test] +async fn test_signer_round_trip_with_mock() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let ctx = default_context(); + + let provider = KeystoneCredentialProvider::new(&mock_url) + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default") + .with_project_name("testproject") + .with_project_domain_name("Default"); + + let signer = Signer::new(ctx, provider, RequestSigner); + + // Build a request to sign + let req = http::Request::builder() + .method("GET") + .uri("http://swift.example.com/v1/AUTH_test/container/object") + .body(()) + .unwrap(); + let (mut parts, _body) = req.into_parts(); + + // Sign it + signer + .sign(&mut parts, None) + .await + .expect("signing must succeed"); + + // Verify the X-Auth-Token header was inserted + let token = parts + .headers + .get("x-auth-token") + .expect("x-auth-token header must be present"); + assert!(!token.is_empty(), "x-auth-token header must not be empty"); + + // Sign a second request — should reuse cached credential, not re-auth + let req2 = http::Request::builder() + .method("PUT") + .uri("http://swift.example.com/v1/AUTH_test/container/object2") + .body(()) + .unwrap(); + let (mut parts2, _body2) = req2.into_parts(); + + signer + .sign(&mut parts2, None) + .await + .expect("second signing must succeed"); + + let token2 = parts2 + .headers + .get("x-auth-token") + .expect("x-auth-token header must be present on second request"); + + // Both should have the same token (cached) + assert_eq!( + token.to_str().unwrap(), + token2.to_str().unwrap(), + "cached token must be reused" + ); +} + +/// Test that connection refused / bad auth URL produces a clear error. +#[tokio::test] +async fn test_keystone_credential_provider_connection_refused() { + let _ = env_logger::builder().is_test(true).try_init(); + + let ctx = default_context(); + + // Use a port that's (almost certainly) not listening + let provider = KeystoneCredentialProvider::new("http://127.0.0.1:19999/v3") + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default"); + + let result = provider.provide_credential(&ctx).await; + assert!( + result.is_err(), + "connection refused must return an error, not silently succeed" + ); +} + +/// Test that empty username/password returns None (skip). +#[tokio::test] +async fn test_keystone_credential_provider_empty_credentials() { + let _ = env_logger::builder().is_test(true).try_init(); + + let ctx = default_context(); + + let provider = KeystoneCredentialProvider::new("http://127.0.0.1:5000/v3"); + // No username/password set — should return None, not error + + let result = provider + .provide_credential(&ctx) + .await + .expect("empty credentials must not error"); + + assert!(result.is_none(), "empty credentials must return None"); +} + +/// Test catalog region filtering with mock server. +#[tokio::test] +async fn test_keystone_catalog_region_filtering_with_mock() { + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST_MOCK").unwrap_or_default() != "on" { + return; + } + + let mock_url = env::var("REQSIGN_OPENSTACK_KEYSTONE_MOCK_URL") + .unwrap_or("http://127.0.0.1:5000/v3".into()); + + let ctx = default_context(); + + let provider = KeystoneCredentialProvider::new(&mock_url) + .with_username("testuser") + .with_password("testpass") + .with_user_domain_name("Default") + .with_project_name("testproject") + .with_project_domain_name("Default"); + + let cred = provider + .provide_credential(&ctx) + .await + .expect("credential loading must succeed") + .expect("credential must be present"); + + // Mock server returns endpoints in "RegionOne" + assert_eq!( + cred.endpoint_in_region("object-store", "public", "RegionOne"), + Some("http://127.0.0.1:8080/v1/AUTH_test"), + "must find object-store public endpoint in RegionOne" + ); + assert_eq!( + cred.endpoint_in_region("object-store", "internal", "RegionOne"), + Some("http://swift-internal:8080/v1/AUTH_test"), + "must find object-store internal endpoint in RegionOne" + ); + + // Non-existent region + assert_eq!( + cred.endpoint_in_region("object-store", "public", "NonExistent"), + None, + "must not find endpoint in non-existent region" + ); +} + +/// Integration test against a real Keystone service. +/// +/// Requires: +/// - REQSIGN_OPENSTACK_KEYSTONE_TEST=on +/// - OPENSTACK_AUTH_URL +/// - OPENSTACK_USERNAME +/// - OPENSTACK_PASSWORD +/// - OPENSTACK_DOMAIN_NAME (optional, defaults to "Default") +/// - OPENSTACK_PROJECT_NAME (optional) +#[tokio::test] +async fn test_real_keystone_env_credential_provider() { + let _ = dotenvy::dotenv(); + let _ = env_logger::builder().is_test(true).try_init(); + + if env::var("REQSIGN_OPENSTACK_KEYSTONE_TEST").unwrap_or_default() != "on" { + return; + } + + let ctx = default_context(); + + let provider = EnvCredentialProvider::new(); + let cred: Credential = provider + .provide_credential(&ctx) + .await + .expect("credential loading must succeed") + .expect("credential must be present"); + + assert!(!cred.token.is_empty(), "token must not be empty"); + assert!(cred.is_valid(), "credential must be valid"); + assert!(cred.expires_at.is_some(), "expires_at must be set"); + + println!("Token obtained successfully"); + println!("Expires at: {:?}", cred.expires_at); + println!("Service catalog entries: {}", cred.service_catalog.len()); + + for entry in &cred.service_catalog { + println!(" Service: {}", entry.service_type); + for ep in &entry.endpoints { + println!(" {} ({:?}): {}", ep.interface, ep.region, ep.url); + } + } +} diff --git a/services/openstack-keystone/tests/mocks/keystone_mock_server.py b/services/openstack-keystone/tests/mocks/keystone_mock_server.py new file mode 100644 index 00000000..4baea86d --- /dev/null +++ b/services/openstack-keystone/tests/mocks/keystone_mock_server.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +Mock server for OpenStack Keystone v3 Identity API. + +This server simulates the Keystone v3 authentication endpoint for testing +the reqsign-openstack-keystone credential provider. +""" + +import json +import sys +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer + + +MOCK_TOKEN = "mock-keystone-token-{timestamp}" + +MOCK_CATALOG = [ + { + "type": "object-store", + "endpoints": [ + { + "interface": "public", + "url": "http://127.0.0.1:8080/v1/AUTH_test", + "region": "RegionOne", + "region_id": "RegionOne", + }, + { + "interface": "internal", + "url": "http://swift-internal:8080/v1/AUTH_test", + "region": "RegionOne", + "region_id": "RegionOne", + }, + ], + }, + { + "type": "identity", + "endpoints": [ + { + "interface": "public", + "url": "http://127.0.0.1:5000/v3", + "region": "RegionOne", + "region_id": "RegionOne", + }, + ], + }, +] + + +class KeystoneHandler(BaseHTTPRequestHandler): + """Handler for Keystone v3 identity API requests.""" + + def do_POST(self): + """Handle POST requests (authentication).""" + if self.path == "/v3/auth/tokens": + self.handle_auth_tokens() + else: + self.send_error(404, "Not Found") + + def do_GET(self): + """Handle GET requests (version discovery).""" + if self.path == "/v3/" or self.path == "/v3": + self.handle_version_discovery() + else: + self.send_error(404, "Not Found") + + def handle_auth_tokens(self): + """Authenticate and return a token.""" + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + + try: + data = json.loads(body) + except json.JSONDecodeError: + self.send_error(400, "Invalid JSON") + return + + # Validate the request structure + auth = data.get("auth", {}) + identity = auth.get("identity", {}) + methods = identity.get("methods", []) + + if "password" not in methods: + self.send_error(400, "Only password authentication is supported") + return + + password_info = identity.get("password", {}) + user = password_info.get("user", {}) + username = user.get("name", "") + password = user.get("password", "") + + if not username or not password: + self.send_error(401, "Invalid credentials") + return + + # Reject known-bad credentials for testing + if password == "wrong-password": + self.send_error(401, "Invalid credentials") + return + + # Generate token response + now = datetime.now(timezone.utc) + expires_at = now + timedelta(hours=1) + token_value = MOCK_TOKEN.format(timestamp=int(now.timestamp())) + + # Check if scope is present — scoped tokens get the catalog, + # unscoped tokens get an empty catalog (matching real Keystone behavior). + scope = auth.get("scope") + has_scope = scope and "project" in scope + + response = { + "token": { + "expires_at": expires_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "issued_at": now.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "methods": ["password"], + "user": { + "name": username, + "domain": user.get("domain", {"name": "Default"}), + }, + "catalog": MOCK_CATALOG if has_scope else [], + } + } + + if has_scope: + response["token"]["project"] = { + "name": scope["project"].get("name", ""), + "domain": scope["project"].get("domain", {"name": "Default"}), + } + + response_body = json.dumps(response).encode() + + self.send_response(201) + self.send_header("Content-Type", "application/json") + self.send_header("X-Subject-Token", token_value) + self.end_headers() + self.wfile.write(response_body) + + def handle_version_discovery(self): + """Return version discovery information.""" + response = { + "version": { + "id": "v3.14", + "status": "stable", + "links": [ + { + "rel": "self", + "href": f"http://127.0.0.1:{self.server.server_port}/v3/", + } + ], + } + } + + response_body = json.dumps(response).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(response_body) + + def log_message(self, format, *args): + """Override to provide custom logging format.""" + sys.stderr.write( + f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {format % args}\n" + ) + + +def run_server(port=5000): + """Run the mock Keystone server.""" + server_address = ("127.0.0.1", port) + httpd = HTTPServer(server_address, KeystoneHandler) + print(f"Mock Keystone Server running on http://127.0.0.1:{port}") + print("Press Ctrl+C to stop") + print("") + print("Test with:") + print(f" curl -X POST http://127.0.0.1:{port}/v3/auth/tokens \\") + print(' -H "Content-Type: application/json" \\') + print( + ' -d \'{"auth":{"identity":{"methods":["password"],"password":{"user":{"name":"test","password":"test","domain":{"name":"Default"}}}}}}\'' + ) + print("") + httpd.serve_forever() + + +if __name__ == "__main__": + port = 5000 + if len(sys.argv) > 1: + port = int(sys.argv[1]) + run_server(port)