From 330b34d950f1c90b56551c20f8037e024771f462 Mon Sep 17 00:00:00 2001 From: Yousef Hussein Date: Fri, 3 Jul 2026 20:00:21 +0300 Subject: [PATCH 1/2] feat(security): Wrap secrets with secrecy crate Wrap every remaining plaintext secret in `secrecy::SecretString` so it can never be exposed through `Debug`, `#[tracing::instrument]`, or accidental serialization, and redact secrets to `"[REDACTED]"` on the serialize paths that feed OPA policy input and audit payloads. Part of issue #369 (token / password / application-credential secret), done as one PR since touching api-types pulls through every downstream crate. Wrapped, edge-to-consumer: - OIDC `oidc_client_secret` across the api DTO, the three core-types domain structs, the driver conversions, and the token-exchange consumer. `oidc_client_id` stays plaintext (per maintainer). - User/auth passwords: lifted the existing deep-layer wrapping up to the wire so `UserCreate`/`UserUpdate`/`UserPassword` and the whole auth request tree are `SecretString` from deserialize down; exposure now happens only at hash/verify/`validate_password`. - Config `invalid_password_hash_key` (mirrors `database.connection`). - OIDC token-exchange `id_token`/`access_token` bearer tokens, which were plaintext in a `Debug`-deriving struct. This also fixes three pre-existing plaintext leaks: `oidc_client_secret` and user `password` were serialized in the clear into OPA policy input via `json!({...})`; they now redact. Security hardening: - Restore the empty-password guard centrally in `SecurityComplianceProvider::validate_password` (rejects an empty password on every write path). The per-DTO `length(min = 1)` check could not be kept on the wrapped field: validator's `custom` requires the field to be `Serialize`, which `SecretString` deliberately is not. Notes: - The Sea-ORM `oidc_client_secret` column stays plaintext `String`; the secret is unwrapped only at the final DB-write boundary. - `PartialEq`/`Default` are dropped where unused and re-implemented manually where tests rely on them (`IdentityProvider`, `UserPasswordAuthRequest`). - OpenAPI schema is byte-for-byte unchanged (fields still `string`). - Adds an adversarial redaction/breakage test suite (flatten+redact, deep-nested serialize, OPA-path, Debug matrix, empty-password guard, response-never-leaks, OIDC token redaction). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yousef Hussein --- Cargo.lock | 1 + .../src/federation/identity_provider.rs | 107 ++++++++++-- crates/api-types/src/lib.rs | 1 + crates/api-types/src/secret_serde.rs | 157 ++++++++++++++++++ crates/api-types/src/v3/auth/token.rs | 100 ++++++++++- crates/api-types/src/v3/user.rs | 131 ++++++++++++++- crates/config/src/security_compliance.rs | 57 ++++++- .../src/federation/identity_provider.rs | 132 ++++++++++++++- crates/core-types/src/identity/user.rs | 61 +++++-- crates/core/src/identity/service.rs | 6 +- crates/federation-driver-sql/Cargo.toml | 1 + .../src/identity_provider.rs | 3 +- .../src/identity_provider/create.rs | 7 +- .../src/identity_provider/update.rs | 4 +- .../identity-driver-sql/src/authenticate.rs | 18 +- crates/identity-driver-sql/src/user/create.rs | 10 +- crates/identity-driver-sql/src/user/update.rs | 12 +- .../keystone/src/api/v3/auth/token/common.rs | 3 +- .../keystone/src/api/v3/auth/token/create.rs | 5 +- crates/keystone/src/federation/api/oidc.rs | 7 +- .../keystone/src/federation/api/oidc_utils.rs | 51 +++++- 21 files changed, 777 insertions(+), 97 deletions(-) create mode 100644 crates/api-types/src/secret_serde.rs diff --git a/Cargo.lock b/Cargo.lock index 9cc23d662..aaf4f29fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4757,6 +4757,7 @@ dependencies = [ "openstack-keystone-core-types", "sea-orm", "sea-orm-migration", + "secrecy", "serde_json", "tokio", "tracing", diff --git a/crates/api-types/src/federation/identity_provider.rs b/crates/api-types/src/federation/identity_provider.rs index 975f1e004..266b419d6 100644 --- a/crates/api-types/src/federation/identity_provider.rs +++ b/crates/api-types/src/federation/identity_provider.rs @@ -12,12 +12,14 @@ // // SPDX-License-Identifier: Apache-2.0 //! Federated identity provider types. +use secrecy::SecretString; use serde::{Deserialize, Serialize}; use serde_json::Value; #[cfg(feature = "validate")] use validator::Validate; use crate::Link; +use crate::secret_serde::{serialize_secret_redacted, serialize_secret_redacted_nested}; /// Identity provider data. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -122,7 +124,10 @@ pub struct IdentityProviderResponse { } /// Identity provider data. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -169,12 +174,21 @@ pub struct IdentityProviderCreate { pub oidc_client_id: Option, /// The oidc `client_secret` to use for the private client. It is never - /// returned back. - #[cfg_attr(feature = "builder", builder(default))] - #[cfg_attr(feature = "openapi", schema(nullable = false))] - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "validate", validate(length(max = 255)))] - pub oidc_client_secret: Option, + /// returned back. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing; redacted (never exposed) when serialized into + /// policy/audit payloads. + /// + /// Field-level `length` validation is intentionally omitted: `SecretString` + /// cannot use validator's built-in `length`/`custom` (the latter requires + /// the field to be `Serialize`, which secrets are not). + #[cfg_attr(feature = "builder", builder(default))] + #[cfg_attr(feature = "openapi", schema(value_type = String, nullable = false))] + #[serde( + default, + serialize_with = "serialize_secret_redacted", + skip_serializing_if = "Option::is_none" + )] + pub oidc_client_secret: Option, /// The oidc response mode. #[cfg_attr(feature = "builder", builder(default))] @@ -240,7 +254,10 @@ pub struct IdentityProviderCreate { } /// New identity provider data. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -271,10 +288,15 @@ pub struct IdentityProviderUpdate { #[cfg_attr(feature = "validate", validate(length(max = 255)))] pub oidc_client_id: Option>, - /// The new oidc `client_secret` to use for the private client. + /// The new oidc `client_secret` to use for the private client. Wrapped in + /// [`SecretString`] to prevent accidental exposure via Debug/tracing; + /// redacted (never exposed) when serialized into policy/audit payloads. + /// Field-level `length` validation is intentionally omitted (see + /// `IdentityProviderCreate`). #[cfg_attr(feature = "builder", builder(default))] - #[cfg_attr(feature = "validate", validate(length(max = 255)))] - pub oidc_client_secret: Option>, + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde(default, serialize_with = "serialize_secret_redacted_nested")] + pub oidc_client_secret: Option>, /// The new oidc response mode. #[cfg_attr(feature = "builder", builder(default))] @@ -324,7 +346,10 @@ pub struct IdentityProviderUpdate { } /// Identity provider create request. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived because the embedded `IdentityProviderCreate` +/// carries a `SecretString`. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct IdentityProviderCreateRequest { @@ -334,7 +359,10 @@ pub struct IdentityProviderCreateRequest { } /// Identity provider update request. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived because the embedded `IdentityProviderUpdate` +/// carries a `SecretString`. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct IdentityProviderUpdateRequest { @@ -383,3 +411,56 @@ pub struct IdentityProviderListParameters { fn default_list_limit() -> Option { Some(20) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The create DTO is serialized into OPA policy input + /// (`json!({"identity_provider": req.identity_provider})`). The client secret + /// must be redacted there. + #[test] + fn idp_create_redacts_client_secret_for_policy() { + let create: IdentityProviderCreate = + serde_json::from_str(r#"{"name":"idp","oidc_client_secret":"CSLEAK"}"#).unwrap(); + let rendered = serde_json::json!({ "identity_provider": &create }).to_string(); + assert!( + !rendered.contains("CSLEAK"), + "leaked client secret: {rendered}" + ); + assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); + } + + /// Same for the update DTO (nested `Option>`). + #[test] + fn idp_update_redacts_client_secret_for_policy() { + let update: IdentityProviderUpdate = + serde_json::from_str(r#"{"oidc_client_secret":"CSLEAK2"}"#).unwrap(); + let rendered = serde_json::json!({ "identity_provider": &update }).to_string(); + assert!( + !rendered.contains("CSLEAK2"), + "leaked client secret: {rendered}" + ); + assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); + } + + /// Regression guard: the read/response DTO has no client-secret field, so a + /// client secret can never be returned — even if one is (wrongly) supplied. + #[test] + fn identity_provider_response_never_exposes_client_secret() { + let idp: IdentityProvider = serde_json::from_str( + r#"{"id":"1","name":"idp","enabled":true, + "oidc_client_secret":"SHOULD_BE_IGNORED"}"#, + ) + .unwrap(); + let rendered = serde_json::to_string(&idp).unwrap(); + assert!( + !rendered.contains("client_secret"), + "response exposed a client_secret field: {rendered}" + ); + assert!( + !rendered.contains("SHOULD_BE_IGNORED"), + "response leaked the secret value: {rendered}" + ); + } +} diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index e9a72a79f..659b0246b 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -30,6 +30,7 @@ pub mod k8s_auth; pub mod scope; #[cfg(feature = "conv")] mod scope_conv; +pub(crate) mod secret_serde; pub mod trust; pub mod v3; pub mod v4; diff --git a/crates/api-types/src/secret_serde.rs b/crates/api-types/src/secret_serde.rs new file mode 100644 index 000000000..5ba2cc87a --- /dev/null +++ b/crates/api-types/src/secret_serde.rs @@ -0,0 +1,157 @@ +// 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 +//! Serialization helpers for [`secrecy::SecretString`] fields. +//! +//! `SecretString` deliberately does not implement `Serialize`. These helpers +//! let DTOs that must remain serializable (e.g. for policy/audit payloads that +//! embed the request or resource) do so **without ever exposing the secret** — +//! a present secret is rendered as a fixed `"[REDACTED]"` marker so field +//! presence is preserved while the value never leaks. +//! +//! This is intentionally different from the "expose once" helper used for +//! secrets that are part of an API contract (e.g. a one-time API-key token): +//! those live next to their own DTO and call `expose_secret()` on purpose. + +use secrecy::SecretString; +use serde::Serializer; + +// NOTE: length/non-empty validation for wrapped secrets is deliberately NOT done +// with validator's field-level `custom` here: validator 0.20 unconditionally +// calls `ValidationError::add_param(&field)`, which requires the field to be +// `Serialize` — and `SecretString` intentionally is not. Password non-emptiness +// is instead enforced centrally in +// `openstack_keystone_config::SecurityComplianceProvider::validate_password`, +// which runs on the wrapped value at the service layer for every write path. + +/// Redact an `Option` on serialize. +pub(crate) fn serialize_secret_redacted( + secret: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(_) => serializer.serialize_str("[REDACTED]"), + None => serializer.serialize_none(), + } +} + +/// Redact a required `SecretString` on serialize. +pub(crate) fn serialize_secret_redacted_required( + _secret: &SecretString, + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.serialize_str("[REDACTED]") +} + +/// Redact the nested `Option>` used by update DTOs, where +/// the outer `Option` is "present-in-request" and the inner is "set-or-clear". +pub(crate) fn serialize_secret_redacted_nested( + secret: &Option>, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(Some(_)) => serializer.serialize_str("[REDACTED]"), + _ => serializer.serialize_none(), + } +} + +#[cfg(test)] +mod tests { + use secrecy::SecretString; + use serde::Serialize; + + use super::*; + + const SECRET: &str = "super-secret-value"; + + #[derive(Serialize)] + struct OptHolder { + #[serde(serialize_with = "serialize_secret_redacted")] + secret: Option, + } + + #[derive(Serialize)] + struct RequiredHolder { + #[serde(serialize_with = "serialize_secret_redacted_required")] + secret: SecretString, + } + + #[derive(Serialize)] + struct NestedHolder { + #[serde(serialize_with = "serialize_secret_redacted_nested")] + secret: Option>, + } + + #[test] + fn opt_secret_is_redacted_when_present() { + let json = serde_json::to_string(&OptHolder { + secret: Some(SecretString::from(SECRET)), + }) + .unwrap(); + assert!(!json.contains(SECRET), "secret leaked: {json}"); + assert!(json.contains("[REDACTED]"), "not redacted: {json}"); + } + + #[test] + fn opt_secret_is_null_when_absent() { + let json = serde_json::to_string(&OptHolder { secret: None }).unwrap(); + assert_eq!(json, r#"{"secret":null}"#); + } + + #[test] + fn required_secret_is_redacted() { + let json = serde_json::to_string(&RequiredHolder { + secret: SecretString::from(SECRET), + }) + .unwrap(); + assert!(!json.contains(SECRET), "secret leaked: {json}"); + assert!(json.contains("[REDACTED]"), "not redacted: {json}"); + } + + #[test] + fn nested_secret_is_redacted_only_when_set() { + // Present + set -> redacted. + let set = serde_json::to_string(&NestedHolder { + secret: Some(Some(SecretString::from(SECRET))), + }) + .unwrap(); + assert!(!set.contains(SECRET), "secret leaked: {set}"); + assert!(set.contains("[REDACTED]"), "not redacted: {set}"); + + // Explicit clear and absent both serialize to null (no value to leak). + assert_eq!( + serde_json::to_string(&NestedHolder { secret: Some(None) }).unwrap(), + r#"{"secret":null}"# + ); + assert_eq!( + serde_json::to_string(&NestedHolder { secret: None }).unwrap(), + r#"{"secret":null}"# + ); + } + + #[test] + fn debug_of_secret_does_not_leak() { + // secrecy's own Debug guarantee, pinned here as the core requirement. + let secret = SecretString::from(SECRET); + assert!(!format!("{secret:?}").contains(SECRET)); + } +} diff --git a/crates/api-types/src/v3/auth/token.rs b/crates/api-types/src/v3/auth/token.rs index 747dcf185..216eea009 100644 --- a/crates/api-types/src/v3/auth/token.rs +++ b/crates/api-types/src/v3/auth/token.rs @@ -13,12 +13,14 @@ // SPDX-License-Identifier: Apache-2.0 use chrono::{DateTime, Utc}; +use secrecy::SecretString; use serde::{Deserialize, Serialize}; #[cfg(feature = "validate")] use validator::Validate; use crate::catalog::*; use crate::scope::*; +use crate::secret_serde::serialize_secret_redacted_required; use crate::trust::TokenTrustRepr; use crate::v3::role::RoleRef; @@ -131,7 +133,10 @@ pub struct TokenResponse { } /// An authentication request. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived: the embedded identity carries a `SecretString` +/// password. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct AuthRequest { @@ -141,7 +146,10 @@ pub struct AuthRequest { } /// An authentication request. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived: the embedded identity carries a `SecretString` +/// password. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct AuthRequestInner { @@ -164,7 +172,9 @@ pub struct AuthRequestInner { } /// An identity object. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived: the embedded password carries a `SecretString`. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -198,7 +208,10 @@ pub struct Identity { } /// The password object, contains the authentication information. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived: the embedded user carries a `SecretString` +/// password. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -216,7 +229,10 @@ pub struct PasswordAuth { } /// User password information. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is intentionally not derived: `password` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -240,9 +256,11 @@ pub struct UserPassword { #[cfg_attr(feature = "builder", builder(default))] #[cfg_attr(feature = "validate", validate(nested))] pub domain: Option, - /// User password. - #[cfg_attr(feature = "validate", validate(length(max = 255)))] - pub password: String, + /// User password. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing; redacted (never exposed) when serialized. + #[cfg_attr(feature = "openapi", schema(value_type = String))] + #[serde(serialize_with = "serialize_secret_redacted_required")] + pub password: SecretString, } /// The TOTP object, contains the authentication information. @@ -376,3 +394,69 @@ pub struct System { /// All. pub all: bool, } + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret; + + use super::*; + + const PWD: &str = "hunter2-plaintext"; + + #[test] + fn user_password_deserializes_plaintext_but_never_leaks() { + // Plaintext arrives on the wire and is accepted into the SecretString. + let up: UserPassword = + serde_json::from_str(&format!(r#"{{"name":"alice","password":"{PWD}"}}"#)).unwrap(); + assert_eq!(up.password.expose_secret(), PWD); + + // Debug must not leak (the #[instrument] / log vector). + assert!( + !format!("{up:?}").contains(PWD), + "Debug leaked the password" + ); + + // Serialization (e.g. into a policy/audit payload) must redact. + let json = serde_json::to_string(&up).unwrap(); + assert!(!json.contains(PWD), "serialize leaked the password: {json}"); + assert!(json.contains("[REDACTED]"), "password not redacted: {json}"); + } + + #[test] + fn full_auth_request_debug_does_not_leak_password() { + let req = nested_auth_request(); + // Debug of the whole nested request tree must not leak the password. + assert!( + !format!("{req:?}").contains(PWD), + "Debug leaked via nested request" + ); + } + + #[test] + fn full_auth_request_serialize_redacts_password_at_depth() { + // The password sits 4 levels deep (auth -> identity -> password -> user). + // Prove redaction survives the whole nested Serialize tree, via both + // `to_string` and `to_value`. + let req = nested_auth_request(); + let as_string = serde_json::to_string(&req).unwrap(); + let as_value = serde_json::to_value(&req).unwrap().to_string(); + for rendered in [as_string, as_value] { + assert!( + !rendered.contains(PWD), + "serialize leaked password at depth: {rendered}" + ); + assert!( + rendered.contains("[REDACTED]"), + "nested password not redacted: {rendered}" + ); + } + } + + fn nested_auth_request() -> AuthRequest { + serde_json::from_str(&format!( + r#"{{"auth":{{"identity":{{"methods":["password"], + "password":{{"user":{{"name":"alice","password":"{PWD}"}}}}}}}}}}"# + )) + .unwrap() + } +} diff --git a/crates/api-types/src/v3/user.rs b/crates/api-types/src/v3/user.rs index b515628fb..1ea4a6ca9 100644 --- a/crates/api-types/src/v3/user.rs +++ b/crates/api-types/src/v3/user.rs @@ -14,11 +14,14 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; +use secrecy::SecretString; use serde::{Deserialize, Serialize}; use serde_json::Value; #[cfg(feature = "validate")] use validator::Validate; +use crate::secret_serde::serialize_secret_redacted; + /// User response object. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[cfg_attr( @@ -102,7 +105,10 @@ pub struct UserResponse { } /// Create user data. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is intentionally not derived: `password` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -154,14 +160,26 @@ pub struct UserCreate { #[cfg_attr(feature = "validate", validate(nested))] pub options: Option, - /// The password for the user. + /// The password for the user. Wrapped in [`SecretString`] to prevent + /// accidental exposure via Debug/tracing; redacted (never exposed) when + /// serialized into policy/audit payloads. Non-emptiness and regex policy are + /// enforced on the wrapped value at the service layer via + /// `security_compliance.validate_password`. #[cfg_attr(feature = "builder", builder(default))] - #[cfg_attr(feature = "validate", validate(length(min = 1, max = 72)))] - pub password: Option, + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde( + default, + serialize_with = "serialize_secret_redacted", + skip_serializing_if = "Option::is_none" + )] + pub password: Option, } /// Complete create user request. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived because the embedded `UserCreate` carries a +/// `SecretString`. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct UserCreateRequest { @@ -171,7 +189,10 @@ pub struct UserCreateRequest { } /// Update user data. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is intentionally not derived: `password` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -220,13 +241,24 @@ pub struct UserUpdate { #[cfg_attr(feature = "validate", validate(nested))] pub options: Option, - /// The password for the user. + /// The password for the user. Wrapped in [`SecretString`] to prevent + /// accidental exposure via Debug/tracing; redacted (never exposed) when + /// serialized into policy/audit payloads. #[cfg_attr(feature = "builder", builder(default))] - pub password: Option, + #[cfg_attr(feature = "openapi", schema(value_type = Option))] + #[serde( + default, + serialize_with = "serialize_secret_redacted", + skip_serializing_if = "Option::is_none" + )] + pub password: Option, } /// Complete update user request. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +/// +/// `PartialEq` is not derived because the embedded `UserUpdate` carries a +/// `SecretString`. +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct UserUpdateRequest { @@ -313,8 +345,89 @@ pub struct UserListParameters { #[cfg(test)] mod tests { + use secrecy::ExposeSecret; + use super::*; + /// Critical: `UserCreate` carries BOTH `#[serde(flatten)] extra` and a + /// `password` with a custom `serialize_with` + `skip_serializing_if`. This is + /// the exact struct the handler serializes into OPA policy input + /// (`json!({"user": req.user})`). Prove the flatten interaction does not + /// leak the password and does not drop `extra`. + #[test] + fn usercreate_flatten_redacts_password_and_keeps_extra() { + let uc: UserCreate = serde_json::from_str( + r#"{"domain_id":"d","name":"alice","enabled":true, + "password":"PWLEAK","x_custom":"xval","y_custom":"yval"}"#, + ) + .unwrap(); + // sanity: password wrapped, extra captured via flatten + assert_eq!( + uc.password.as_ref().map(|s| s.expose_secret()), + Some("PWLEAK") + ); + assert_eq!( + uc.extra.get("x_custom").and_then(|v| v.as_str()), + Some("xval") + ); + + // Both the raw serialize and the OPA-shaped `json!({"user": uc})` path. + let direct = serde_json::to_string(&uc).unwrap(); + let opa = serde_json::json!({ "user": &uc }).to_string(); + for rendered in [direct, opa] { + assert!( + !rendered.contains("PWLEAK"), + "flatten leaked password: {rendered}" + ); + assert!( + rendered.contains("[REDACTED]"), + "password not redacted: {rendered}" + ); + assert!( + rendered.contains("x_custom") && rendered.contains("xval"), + "extra dropped by flatten+redact: {rendered}" + ); + assert!( + rendered.contains("y_custom") && rendered.contains("yval"), + "extra dropped by flatten+redact: {rendered}" + ); + } + } + + /// `UserUpdate` has the same flatten+redact shape. + #[test] + fn userupdate_flatten_redacts_password_and_keeps_extra() { + let uu: UserUpdate = + serde_json::from_str(r#"{"password":"UPWLEAK","z_extra":"zz"}"#).unwrap(); + let rendered = serde_json::json!({ "user": &uu }).to_string(); + assert!(!rendered.contains("UPWLEAK"), "leaked: {rendered}"); + assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); + assert!(rendered.contains("z_extra"), "extra dropped: {rendered}"); + } + + /// Explicit `null` and absent password both deserialize to `None` (no panic, + /// no plaintext resurrected). + #[test] + fn usercreate_password_null_and_absent_are_none() { + let with_null: UserCreate = + serde_json::from_str(r#"{"domain_id":"d","name":"a","enabled":true,"password":null}"#) + .unwrap(); + assert!(with_null.password.is_none()); + let absent: UserCreate = + serde_json::from_str(r#"{"domain_id":"d","name":"a","enabled":true}"#).unwrap(); + assert!(absent.password.is_none()); + } + + /// Debug of a populated `UserCreate` never renders the password. + #[test] + fn usercreate_debug_does_not_leak_password() { + let uc: UserCreate = serde_json::from_str( + r#"{"domain_id":"d","name":"a","enabled":true,"password":"DBGLEAK"}"#, + ) + .unwrap(); + assert!(!format!("{uc:?}").contains("DBGLEAK")); + } + #[cfg(feature = "builder")] #[test] fn test_user_create() { diff --git a/crates/config/src/security_compliance.rs b/crates/config/src/security_compliance.rs index f0b87a7fa..df99b998e 100644 --- a/crates/config/src/security_compliance.rs +++ b/crates/config/src/security_compliance.rs @@ -67,8 +67,11 @@ pub struct SecurityComplianceProvider { /// used in distributed installations working with the same backend, to make /// them all generate equal hashes for equal invalid passwords). 16 bytes /// (128 bits) or more is recommended. + /// + /// Wrapped in [`SecretString`] to prevent accidental exposure via + /// Debug/tracing (mirrors `database.connection`). #[serde(default)] - pub invalid_password_hash_key: Option, + pub invalid_password_hash_key: Option, /// This option has a sample default set, which means that its actual /// default value may vary from the one documented above. /// @@ -225,16 +228,27 @@ impl SecurityComplianceProvider { Ok(()) } - /// Validate a password against the configured regex pattern. + /// Validate a password against the security-compliance policy. + /// + /// An empty password is **always** rejected, regardless of configuration — + /// this is the central guard for every write path (create/update/change), + /// and replaces the per-DTO `length(min = 1)` check that could not be kept on + /// the wrapped [`SecretString`] fields (validator's `custom`/`length` require + /// the field to be `Serialize`, which secrets deliberately are not). /// - /// Returns `Ok(())` when the password matches the configured pattern, or - /// when no pattern is configured. Returns - /// `Err(SecurityComplianceError::PasswordInvalid)` - /// with the human-readable policy description on mismatch. + /// Beyond that, returns `Ok(())` when the password matches the configured + /// regex pattern (or when no pattern is configured), otherwise + /// `Err(SecurityComplianceError::PasswordInvalid)` with the human-readable + /// policy description. pub fn validate_password( &self, password: &SecretString, ) -> Result<(), SecurityComplianceError> { + if password.expose_secret().is_empty() { + return Err(SecurityComplianceError::PasswordInvalid( + "password must not be empty".to_string(), + )); + } if let Some(ref re) = self.password_regex_re && !re.is_match(password.expose_secret()) { @@ -405,4 +419,35 @@ mod tests { assert!(cfg.compile_regex().is_ok()); assert!(cfg.password_regex_re.is_none()); } + + /// Regression guard for #369: wrapping the password in `SecretString` meant + /// the per-DTO `length(min = 1)` guard could not be kept (validator's + /// `custom` needs the field to be `Serialize`). The non-empty check moved + /// here and MUST reject an empty password even when no regex is configured. + #[test] + fn validate_password_rejects_empty_without_regex() { + let sc = SecurityComplianceProvider::default(); + assert!(sc.password_regex_re.is_none(), "precondition: no regex"); + let result = sc.validate_password(&SecretString::from("")); + assert!( + matches!(result, Err(SecurityComplianceError::PasswordInvalid(_))), + "empty password must be rejected without a regex, got {result:?}" + ); + // A non-empty password still passes when no policy is configured. + assert!(sc.validate_password(&SecretString::from("x")).is_ok()); + } + + /// The `invalid_password_hash_key` secret must never render under `Debug` + /// (Sea-ORM/tracing could otherwise log the whole config section). + #[test] + fn invalid_password_hash_key_redacts_under_debug() { + let sc = SecurityComplianceProvider { + invalid_password_hash_key: Some(SecretString::from("HASHKEYLEAK")), + ..Default::default() + }; + assert!( + !format!("{sc:?}").contains("HASHKEYLEAK"), + "invalid_password_hash_key leaked via Debug" + ); + } } diff --git a/crates/core-types/src/federation/identity_provider.rs b/crates/core-types/src/federation/identity_provider.rs index 2405f3521..88886d0ee 100644 --- a/crates/core-types/src/federation/identity_provider.rs +++ b/crates/core-types/src/federation/identity_provider.rs @@ -14,13 +14,32 @@ //! # Federated identity provider types use derive_builder::Builder; -use serde::Serialize; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Serialize, Serializer}; use serde_json::Value; use crate::error::BuilderError; +/// Serialize an optional secret as a fixed redaction marker so that it never +/// leaks in Debug/policy/audit payloads while still signalling presence. +fn serialize_secret_redacted( + secret: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(_) => serializer.serialize_str("[REDACTED]"), + None => serializer.serialize_none(), + } +} + /// Identity provider resource. -#[derive(Builder, Clone, Debug, Default, Serialize, PartialEq)] +/// +/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Builder, Clone, Debug, Default, Serialize)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct IdentityProvider { @@ -45,8 +64,12 @@ pub struct IdentityProvider { #[builder(default)] pub oidc_client_id: Option, + /// The OIDC client secret. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing; redacted (never exposed) when serialized into + /// policy/audit payloads. #[builder(default)] - pub oidc_client_secret: Option, + #[serde(serialize_with = "serialize_secret_redacted")] + pub oidc_client_secret: Option, #[builder(default)] pub oidc_response_mode: Option, @@ -81,8 +104,42 @@ pub struct IdentityProvider { pub provider_config: Option, } +/// Manual `PartialEq` (the derive cannot be used because `oidc_client_secret` +/// is a [`SecretString`], which does not implement `PartialEq`). Preserves the +/// pre-wrapping equality contract by comparing the exposed secret values. +impl PartialEq for IdentityProvider { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.name == other.name + && self.domain_id == other.domain_id + && self.enabled == other.enabled + && self.oidc_discovery_url == other.oidc_discovery_url + && self.oidc_client_id == other.oidc_client_id + && self + .oidc_client_secret + .as_ref() + .map(ExposeSecret::expose_secret) + == other + .oidc_client_secret + .as_ref() + .map(ExposeSecret::expose_secret) + && self.oidc_response_mode == other.oidc_response_mode + && self.oidc_response_types == other.oidc_response_types + && self.jwks_url == other.jwks_url + && self.jwt_validation_pubkeys == other.jwt_validation_pubkeys + && self.bound_issuer == other.bound_issuer + && self.default_mapping_name == other.default_mapping_name + && self.oidc_scopes == other.oidc_scopes + && self.allowed_redirect_uris == other.allowed_redirect_uris + && self.provider_config == other.provider_config + } +} + /// New Identity provider data. -#[derive(Builder, Clone, Debug, Default, PartialEq)] +/// +/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Builder, Clone, Debug, Default)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct IdentityProviderCreate { @@ -107,8 +164,10 @@ pub struct IdentityProviderCreate { #[builder(default)] pub oidc_client_id: Option, + /// The OIDC client secret. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing. #[builder(default)] - pub oidc_client_secret: Option, + pub oidc_client_secret: Option, #[builder(default)] pub oidc_response_mode: Option, @@ -141,7 +200,10 @@ pub struct IdentityProviderCreate { } /// Identity provider update data. -#[derive(Builder, Clone, Debug, Default, PartialEq)] +/// +/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Builder, Clone, Debug, Default)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(into))] pub struct IdentityProviderUpdate { @@ -157,8 +219,11 @@ pub struct IdentityProviderUpdate { #[builder(default)] pub oidc_client_id: Option>, + /// The OIDC client secret. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing. Outer `Option` = present-in-request, + /// inner `Option` = set-or-clear. #[builder(default)] - pub oidc_client_secret: Option>, + pub oidc_client_secret: Option>, #[builder(default)] pub oidc_response_mode: Option>, @@ -211,3 +276,56 @@ pub struct IdentityProviderListParameters { /// Filters the response by IDP name. pub name: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "oidc-top-secret"; + + #[test] + fn identity_provider_never_leaks_client_secret() { + let idp = IdentityProviderBuilder::default() + .id("1") + .name("idp") + .oidc_client_secret(SECRET) + .build() + .unwrap(); + + // Debug (the #[instrument] / log vector) must not leak. + assert!( + !format!("{idp:?}").contains(SECRET), + "Debug leaked client secret" + ); + + // Serialization into the OPA policy / audit payload must redact. + let json = serde_json::to_string(&idp).unwrap(); + assert!( + !json.contains(SECRET), + "serialize leaked client secret: {json}" + ); + assert!( + json.contains("[REDACTED]"), + "client secret not redacted: {json}" + ); + } + + #[test] + fn partial_eq_still_compares_client_secret() { + let base = IdentityProviderBuilder::default() + .id("1") + .name("idp") + .oidc_client_secret(SECRET) + .build() + .unwrap(); + let same = base.clone(); + let different = IdentityProviderBuilder::default() + .id("1") + .name("idp") + .oidc_client_secret("other-secret") + .build() + .unwrap(); + assert_eq!(base, same); + assert_ne!(base, different); + } +} diff --git a/crates/core-types/src/identity/user.rs b/crates/core-types/src/identity/user.rs index 2e0d6b888..8b1e8a928 100644 --- a/crates/core-types/src/identity/user.rs +++ b/crates/core-types/src/identity/user.rs @@ -15,12 +15,19 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use derive_builder::Builder; +use secrecy::SecretString; use serde::Serialize; use serde_json::Value; use validator::Validate; use crate::error::BuilderError; +// NOTE: password length/non-emptiness is not validated with a field-level +// validator here — `SecretString` cannot use validator's `length` (no +// `ValidateLength`) nor `custom` (which requires the field to be `Serialize`). +// Non-emptiness is enforced centrally on the wrapped value at the service layer +// via `security_compliance.validate_password`. + #[derive(Builder, Clone, Debug, PartialEq, Serialize, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] @@ -75,7 +82,10 @@ pub struct UserResponse { } /// User creation data. -#[derive(Builder, Clone, Debug, PartialEq, Validate)] +/// +/// `PartialEq` is intentionally not derived: `password` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Builder, Clone, Debug, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct UserCreate { @@ -119,13 +129,19 @@ pub struct UserCreate { #[validate(nested)] pub options: Option, - /// User password. + /// User password. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing. Non-emptiness and regex policy are enforced on + /// the wrapped value at the service layer via + /// `security_compliance.validate_password`. #[builder(default)] - #[validate(length(max = 72))] - pub password: Option, + pub password: Option, } -#[derive(Builder, Clone, Debug, Default, PartialEq, Validate)] +/// User update data. +/// +/// `PartialEq` is intentionally not derived: `password` is wrapped in +/// [`SecretString`], which does not implement `PartialEq` by design. +#[derive(Builder, Clone, Debug, Default, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct UserUpdate { @@ -161,10 +177,11 @@ pub struct UserUpdate { #[validate(nested)] pub options: Option, - /// New user password. + /// New user password. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing. Non-emptiness/policy enforced at the service + /// layer via `security_compliance.validate_password`. #[builder(default)] - #[validate(length(max = 72))] - pub password: Option, + pub password: Option, } /// User options. @@ -271,7 +288,10 @@ pub enum UserType { } /// User password information. -#[derive(Builder, Clone, Debug, Default, PartialEq, Validate)] +/// +/// `Default` and `PartialEq` are intentionally not derived: `password` is a +/// required [`SecretString`], which implements neither by design. +#[derive(Builder, Clone, Debug, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct UserPasswordAuthRequest { @@ -290,10 +310,25 @@ pub struct UserPasswordAuthRequest { #[validate(nested)] pub domain: Option, - /// User password expiry date. - #[builder(default)] - #[validate(length(max = 72))] - pub password: String, + /// User password. Wrapped in [`SecretString`] to prevent accidental + /// exposure via Debug/tracing. Required (no builder default: `SecretString` + /// does not implement `Default`). + pub password: SecretString, +} + +/// Manual `Default` (the derive cannot be used because `SecretString` does not +/// implement `Default`). Preserves the pre-wrapping default of an empty +/// password. Production code constructs this via the builder, which requires an +/// explicit password. +impl Default for UserPasswordAuthRequest { + fn default() -> Self { + Self { + id: None, + name: None, + domain: None, + password: SecretString::from(""), + } + } } /// User TOTP authentication request. diff --git a/crates/core/src/identity/service.rs b/crates/core/src/identity/service.rs index 0cb622aa1..a30b2ac59 100644 --- a/crates/core/src/identity/service.rs +++ b/crates/core/src/identity/service.rs @@ -589,8 +589,7 @@ impl IdentityApi for IdentityService { // Validate password against configured regex pattern. if let Some(ref password) = mod_user.password { let cfg = ctx.state().config_manager.config.read().await; - cfg.security_compliance - .validate_password(&SecretString::from(password.as_str()))?; + cfg.security_compliance.validate_password(password)?; } let user = if let Some(vsc) = ctx.ctx() { let backend_driver = &self.backend_driver; @@ -1165,8 +1164,7 @@ impl IdentityApi for IdentityService { // Validate password against configured regex pattern. if let Some(ref password) = user.password { let cfg = ctx.state().config_manager.config.read().await; - cfg.security_compliance - .validate_password(&SecretString::from(password.as_str()))?; + cfg.security_compliance.validate_password(password)?; } let user = if let Some(vsc) = ctx.ctx() { let backend_driver = &self.backend_driver; diff --git a/crates/federation-driver-sql/Cargo.toml b/crates/federation-driver-sql/Cargo.toml index b338bae68..d1e52764e 100644 --- a/crates/federation-driver-sql/Cargo.toml +++ b/crates/federation-driver-sql/Cargo.toml @@ -17,6 +17,7 @@ inventory.workspace = true openstack-keystone-core.workspace = true openstack-keystone-core-types.workspace = true sea-orm = { workspace = true, features = ["default"] } +secrecy.workspace = true sea-orm-migration.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/federation-driver-sql/src/identity_provider.rs b/crates/federation-driver-sql/src/identity_provider.rs index 2757b6572..8197a9055 100644 --- a/crates/federation-driver-sql/src/identity_provider.rs +++ b/crates/federation-driver-sql/src/identity_provider.rs @@ -47,7 +47,8 @@ impl TryFrom for IdentityProvider { builder.oidc_client_id(val); } if let Some(val) = &value.oidc_client_secret { - builder.oidc_client_secret(val); + // Wrap the plaintext DB value back into a SecretString on read. + builder.oidc_client_secret(val.as_str()); } if let Some(val) = &value.oidc_response_mode { builder.oidc_response_mode(val); diff --git a/crates/federation-driver-sql/src/identity_provider/create.rs b/crates/federation-driver-sql/src/identity_provider/create.rs index b59a80945..477c0ed45 100644 --- a/crates/federation-driver-sql/src/identity_provider/create.rs +++ b/crates/federation-driver-sql/src/identity_provider/create.rs @@ -16,6 +16,7 @@ use sea_orm::DatabaseConnection; use sea_orm::TransactionTrait; use sea_orm::entity::*; use sea_orm::sea_query::OnConflict; +use secrecy::ExposeSecret; use uuid::Uuid; use openstack_keystone_core::error::DbContextExt; @@ -61,10 +62,12 @@ pub async fn create( .unwrap_or(NotSet) .into(), oidc_client_id: idp.oidc_client_id.clone().map(Set).unwrap_or(NotSet).into(), + // Exposure boundary: the secret is persisted as plaintext in the DB + // column, so it is unwrapped only here at the final storage write. oidc_client_secret: idp .oidc_client_secret - .clone() - .map(Set) + .as_ref() + .map(|s| Set(s.expose_secret().to_string())) .unwrap_or(NotSet) .into(), oidc_response_mode: idp diff --git a/crates/federation-driver-sql/src/identity_provider/update.rs b/crates/federation-driver-sql/src/identity_provider/update.rs index 62ed1902f..8926e62fe 100644 --- a/crates/federation-driver-sql/src/identity_provider/update.rs +++ b/crates/federation-driver-sql/src/identity_provider/update.rs @@ -15,6 +15,7 @@ use sea_orm::DatabaseConnection; use sea_orm::TransactionTrait; use sea_orm::entity::*; +use secrecy::ExposeSecret; use openstack_keystone_core::error::DbContextExt; use openstack_keystone_core::federation::FederationProviderError; @@ -74,7 +75,8 @@ pub async fn update>( entry.oidc_client_id = Set(val.to_owned()); } if let Some(val) = idp.oidc_client_secret { - entry.oidc_client_secret = Set(val.to_owned()); + // Exposure boundary: unwrapped only here at the final storage write. + entry.oidc_client_secret = Set(val.map(|s| s.expose_secret().to_string())); } if let Some(val) = idp.oidc_response_mode { entry.oidc_response_mode = Set(val.to_owned()); diff --git a/crates/identity-driver-sql/src/authenticate.rs b/crates/identity-driver-sql/src/authenticate.rs index b1291f903..b0309c07d 100644 --- a/crates/identity-driver-sql/src/authenticate.rs +++ b/crates/identity-driver-sql/src/authenticate.rs @@ -14,6 +14,7 @@ //! User account authentication implementation. use chrono::Utc; use sea_orm::DatabaseConnection; +use secrecy::ExposeSecret; use tracing::info; use openstack_keystone_config::Config; @@ -79,9 +80,11 @@ pub async fn authenticate_by_password( .await .map_err(IdentityProviderError::password_hash)?; - let _ = password_hashing::verify_password(config, &auth.password, dummy_hash) - .await - .map_err(IdentityProviderError::password_hash)?; + // Exposure boundary: unwrapped only to feed the constant-time verify. + let _ = + password_hashing::verify_password(config, auth.password.expose_secret(), dummy_hash) + .await + .map_err(IdentityProviderError::password_hash)?; return Err(AuthenticationError::UserNameOrPasswordWrong.into()); } @@ -128,7 +131,8 @@ pub async fn authenticate_by_password( // Verify the password let now = Utc::now(); - if !password_hashing::verify_password(config, &auth.password, expected_hash) + // Exposure boundary: unwrapped only to feed the constant-time verify. + if !password_hashing::verify_password(config, auth.password.expose_secret(), expected_hash) .await .map_err(IdentityProviderError::password_hash)? { @@ -473,7 +477,7 @@ mod tests { &db, &UserPasswordAuthRequest { id: Some("user_id".into()), - password, + password: password.clone().into(), ..Default::default() }, ) @@ -877,7 +881,7 @@ mod tests { &db, &UserPasswordAuthRequest { id: Some("user_id".into()), - password: password.clone(), + password: password.clone().into(), ..Default::default() }, ) @@ -927,7 +931,7 @@ mod tests { &db, &UserPasswordAuthRequest { id: Some("user_id".into()), - password: password.clone(), + password: password.clone().into(), ..Default::default() }, ) diff --git a/crates/identity-driver-sql/src/user/create.rs b/crates/identity-driver-sql/src/user/create.rs index 0a6460816..93d3a6d9b 100644 --- a/crates/identity-driver-sql/src/user/create.rs +++ b/crates/identity-driver-sql/src/user/create.rs @@ -189,14 +189,8 @@ pub async fn create( response_builder.merge_local_user_data(&local_user); if let Some(password) = &user.password { - password::set_new_password( - &txn, - conf, - local_user.id, - secrecy::SecretString::from(password.as_str()), - Vec::new(), - ) - .await?; + password::set_new_password(&txn, conf, local_user.id, password.clone(), Vec::new()) + .await?; response_builder.merge_passwords_data(password::list(&txn, local_user.id).await?); } } diff --git a/crates/identity-driver-sql/src/user/update.rs b/crates/identity-driver-sql/src/user/update.rs index 687e9275b..1f8f5da39 100644 --- a/crates/identity-driver-sql/src/user/update.rs +++ b/crates/identity-driver-sql/src/user/update.rs @@ -109,7 +109,7 @@ pub async fn update( &txn, conf, local_user.id, - secrecy::SecretString::from(new_password.as_str()), + new_password.clone(), passwords_vec, ) .await?; @@ -256,7 +256,7 @@ mod tests { .into_connection(); let req = UserUpdate { - password: Some("new_password".to_string()), + password: Some("new_password".into()), ..Default::default() }; @@ -330,7 +330,7 @@ mod tests { .into_connection(); let req = UserUpdate { - password: Some("new_password".to_string()), + password: Some("new_password".into()), ..Default::default() }; @@ -376,7 +376,7 @@ mod tests { .into_connection(); let req = UserUpdate { - password: Some("new_password".to_string()), + password: Some("new_password".into()), ..Default::default() }; @@ -416,7 +416,7 @@ mod tests { let req = UserUpdate { name: Some("new_name".to_string()), - password: Some("new_password".to_string()), + password: Some("new_password".into()), ..Default::default() }; @@ -463,7 +463,7 @@ mod tests { .into_connection(); let req = UserUpdate { - password: Some(new_password.to_string()), + password: Some(new_password.into()), ..Default::default() }; diff --git a/crates/keystone/src/api/v3/auth/token/common.rs b/crates/keystone/src/api/v3/auth/token/common.rs index dfee06d37..82338bf40 100644 --- a/crates/keystone/src/api/v3/auth/token/common.rs +++ b/crates/keystone/src/api/v3/auth/token/common.rs @@ -87,6 +87,7 @@ mod tests { use openstack_keystone_core_types::auth::*; use openstack_keystone_core_types::identity::{UserPasswordAuthRequest, UserResponseBuilder}; use openstack_keystone_core_types::resource::Domain; + use secrecy::ExposeSecret; use super::super::types::*; use super::*; @@ -116,7 +117,7 @@ mod tests { .expect_authenticate_by_password() .withf(|_, req: &UserPasswordAuthRequest| { req.id == Some("uid".to_string()) - && req.password == "pwd" + && req.password.expose_secret() == "pwd" && req.name == Some("uname".to_string()) }) .returning(move |_, _| Ok(auth_clone.clone())); diff --git a/crates/keystone/src/api/v3/auth/token/create.rs b/crates/keystone/src/api/v3/auth/token/create.rs index ccf118209..0c4726a32 100644 --- a/crates/keystone/src/api/v3/auth/token/create.rs +++ b/crates/keystone/src/api/v3/auth/token/create.rs @@ -156,6 +156,7 @@ mod tests { use openstack_keystone_core_types::identity::UserPasswordAuthRequest; use openstack_keystone_core_types::resource::{Domain, DomainBuilder, Project}; use openstack_keystone_core_types::token::{ProjectScopePayload, TokenProviderError}; + use secrecy::ExposeSecret; use crate::api::v3::auth::token::types::*; use crate::assignment::MockAssignmentProvider; @@ -213,7 +214,7 @@ mod tests { .expect_authenticate_by_password() .withf(|_, req: &UserPasswordAuthRequest| { req.id == Some("uid".to_string()) - && req.password == "pass" + && req.password.expose_secret() == "pass" && req.name == Some("uname".to_string()) }) .returning(move |_, _| Ok(auth.clone())); @@ -442,7 +443,7 @@ mod tests { .expect_authenticate_by_password() .withf(|_, req: &UserPasswordAuthRequest| { req.id == Some("uid".to_string()) - && req.password == "pass" + && req.password.expose_secret() == "pass" && req.name == Some("uname".to_string()) }) .returning(move |_, _| Ok(auth.clone())); diff --git a/crates/keystone/src/federation/api/oidc.rs b/crates/keystone/src/federation/api/oidc.rs index 331729a4b..ecdbef48d 100644 --- a/crates/keystone/src/federation/api/oidc.rs +++ b/crates/keystone/src/federation/api/oidc.rs @@ -15,6 +15,7 @@ use axum::{Json, debug_handler, extract::State, http::StatusCode, response::IntoResponse}; use chrono::Utc; +use secrecy::ExposeSecret; use url::Url; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -157,7 +158,8 @@ async fn callback_inner( let token_response = exchange_code( &metadata.token_endpoint, client_id, - idp.oidc_client_secret.as_deref(), + // Exposure boundary: unwrapped only to build the token-endpoint request. + idp.oidc_client_secret.as_ref().map(|s| s.expose_secret()), &query.code, &auth_state.redirect_uri, &auth_state.pkce_verifier, @@ -171,7 +173,8 @@ async fn callback_inner( // claim MUST contain the RP's client ID. Validate issuer, nonce, and // audience in a single verification pass. let claims_value: serde_json::Value = verify_jwt( - &id_token, + // Exposure boundary: the ID token is unwrapped only to verify it. + id_token.expose_secret(), &jwks, Some(metadata.issuer.as_str()), Some(&auth_state.nonce), diff --git a/crates/keystone/src/federation/api/oidc_utils.rs b/crates/keystone/src/federation/api/oidc_utils.rs index 4738d910c..768edbeb1 100644 --- a/crates/keystone/src/federation/api/oidc_utils.rs +++ b/crates/keystone/src/federation/api/oidc_utils.rs @@ -21,6 +21,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{ Algorithm, DecodingKey, Header, TokenData, Validation, decode, decode_header, jwk::JwkSet, }; +use secrecy::SecretString; use serde::Deserialize; use sha2::{Digest, Sha256}; use url::Url; @@ -129,11 +130,15 @@ pub(super) fn build_auth_url( } /// Response body from the token endpoint. +/// +/// `id_token` and `access_token` are OAuth/OIDC bearer tokens: wrapped in +/// [`SecretString`] so this `Debug`-deriving struct can never leak them through +/// tracing/logs. They are consumed only at the JWT-verification boundary. #[derive(Debug, Deserialize)] pub(super) struct TokenExchangeResponse { - pub id_token: Option, + pub id_token: Option, #[allow(dead_code)] - pub access_token: Option, + pub access_token: Option, #[allow(dead_code)] pub token_type: String, #[allow(dead_code)] @@ -335,6 +340,8 @@ pub(super) fn build_http_client() -> Result { #[cfg(test)] mod tests { + use secrecy::ExposeSecret; + use super::*; use chrono::{TimeDelta, Utc}; use httpmock::prelude::*; @@ -701,8 +708,35 @@ nCCsPCcZ_m39ehWRD5EuL3yrQGE7HJo2a7E9J2bb0xBQEzXd_UzBI-lOOw2nvwIm\ .await .unwrap(); - assert_eq!(resp.access_token, Some("at-value".to_string())); - assert_eq!(resp.id_token, Some("id.token.value".to_string())); + assert_eq!( + resp.access_token.as_ref().map(|s| s.expose_secret()), + Some("at-value") + ); + assert_eq!( + resp.id_token.as_ref().map(|s| s.expose_secret()), + Some("id.token.value") + ); + } + + #[test] + fn token_exchange_response_debug_never_leaks_tokens() { + let resp: TokenExchangeResponse = serde_json::from_value(json!({ + "id_token": "id.token.LEAKME", + "access_token": "access.token.LEAKME", + "token_type": "Bearer", + })) + .unwrap(); + // The Debug-derived struct must never render the bearer tokens. + let dbg = format!("{resp:?}"); + assert!( + !dbg.contains("LEAKME"), + "Debug leaked OIDC bearer tokens: {dbg}" + ); + // ...but the values remain retrievable at the exposure boundary. + assert_eq!( + resp.id_token.as_ref().map(|s| s.expose_secret()), + Some("id.token.LEAKME") + ); } #[tokio::test] @@ -1123,11 +1157,14 @@ nCCsPCcZ_m39ehWRD5EuL3yrQGE7HJo2a7E9J2bb0xBQEzXd_UzBI-lOOw2nvwIm\ .await .unwrap(); - assert_eq!( - resp.access_token, None, + assert!( + resp.access_token.is_none(), "access_token must be absent in id-token-only response" ); - assert_eq!(resp.id_token, Some("id.token.value".to_string())); + assert_eq!( + resp.id_token.as_ref().map(|s| s.expose_secret()), + Some("id.token.value") + ); } // ── iat validation ──────────────────────────────────────────────────────── From 2e15c4717fa90b0953eb88b65a1c8cc3531e4faa Mon Sep 17 00:00:00 2001 From: Yousef Hussein Date: Sat, 4 Jul 2026 00:52:21 +0300 Subject: [PATCH 2/2] refactor(security): Use SecretString directly Address review feedback on the secret wrapping. Drop the SecretField newtype. `secrecy::SecretString` already keeps the value out of `Debug`, which is the reason the crate is used, so the DTO fields hold a `SecretString` directly and expose it for transport with a small `serialize_with` helper, the same way `K8sAuthRequest` does. - Keep api-types decoupled from core-types: the dependency is optional again and gated behind the builder/conv features as before, so a crate that only wants the API types does not pull it in. api-types uses `secrecy` directly, which it already depended on. - Drop `PartialEq` from the secret-bearing structs. `SecretString` does not implement it and `K8sAuthRequest` does not derive it either, so there is no hand-written impl to forget a field. The two driver tests that compared a whole `IdentityProvider` now compare fields. - Wrap the TOTP `passcode` and the token-auth `id`, and skip the domain identity provider's client secret on serialize since it is never returned. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yousef Hussein --- .../src/federation/identity_provider.rs | 99 ++++++----- crates/api-types/src/lib.rs | 1 - crates/api-types/src/secret_serde.rs | 157 ------------------ crates/api-types/src/v3/auth/token.rs | 79 +++++---- crates/api-types/src/v3/auth_conv.rs | 3 +- crates/api-types/src/v3/user.rs | 113 ++++++------- .../src/federation/identity_provider.rs | 111 +++---------- crates/core-types/src/identity/user.rs | 25 ++- crates/core/src/identity/service.rs | 10 +- .../src/identity_provider/get.rs | 16 +- .../src/identity_provider/list.rs | 54 +++--- .../keystone/src/api/v3/auth/token/common.rs | 7 +- 12 files changed, 223 insertions(+), 452 deletions(-) delete mode 100644 crates/api-types/src/secret_serde.rs diff --git a/crates/api-types/src/federation/identity_provider.rs b/crates/api-types/src/federation/identity_provider.rs index 266b419d6..0c25b03bc 100644 --- a/crates/api-types/src/federation/identity_provider.rs +++ b/crates/api-types/src/federation/identity_provider.rs @@ -12,14 +12,45 @@ // // SPDX-License-Identifier: Apache-2.0 //! Federated identity provider types. -use secrecy::SecretString; -use serde::{Deserialize, Serialize}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize, Serializer}; use serde_json::Value; #[cfg(feature = "validate")] use validator::Validate; use crate::Link; -use crate::secret_serde::{serialize_secret_redacted, serialize_secret_redacted_nested}; + +/// Serialize an optional client secret transparently for transport (the client +/// must send the real value). `Debug` redaction from `SecretString` is what +/// keeps it out of logs; the value is never serialized back to a client because +/// the response type has no secret field. +fn serialize_optional_secret( + secret: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(secret) => serializer.serialize_some(secret.expose_secret()), + None => serializer.serialize_none(), + } +} + +/// Same as [`serialize_optional_secret`] for the update DTO's nested option +/// (outer = present-in-request, inner = set-or-clear). +fn serialize_nested_optional_secret( + secret: &Option>, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(Some(secret)) => serializer.serialize_some(secret.expose_secret()), + _ => serializer.serialize_none(), + } +} /// Identity provider data. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -124,9 +155,6 @@ pub struct IdentityProviderResponse { } /// Identity provider data. -/// -/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -174,19 +202,13 @@ pub struct IdentityProviderCreate { pub oidc_client_id: Option, /// The oidc `client_secret` to use for the private client. It is never - /// returned back. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing; redacted (never exposed) when serialized into - /// policy/audit payloads. - /// - /// Field-level `length` validation is intentionally omitted: `SecretString` - /// cannot use validator's built-in `length`/`custom` (the latter requires - /// the field to be `Serialize`, which secrets are not). + /// returned back. #[cfg_attr(feature = "builder", builder(default))] #[cfg_attr(feature = "openapi", schema(value_type = String, nullable = false))] #[serde( default, - serialize_with = "serialize_secret_redacted", - skip_serializing_if = "Option::is_none" + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_secret" )] pub oidc_client_secret: Option, @@ -254,9 +276,6 @@ pub struct IdentityProviderCreate { } /// New identity provider data. -/// -/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -288,14 +307,10 @@ pub struct IdentityProviderUpdate { #[cfg_attr(feature = "validate", validate(length(max = 255)))] pub oidc_client_id: Option>, - /// The new oidc `client_secret` to use for the private client. Wrapped in - /// [`SecretString`] to prevent accidental exposure via Debug/tracing; - /// redacted (never exposed) when serialized into policy/audit payloads. - /// Field-level `length` validation is intentionally omitted (see - /// `IdentityProviderCreate`). + /// The new oidc `client_secret` to use for the private client. #[cfg_attr(feature = "builder", builder(default))] #[cfg_attr(feature = "openapi", schema(value_type = Option))] - #[serde(default, serialize_with = "serialize_secret_redacted_nested")] + #[serde(default, serialize_with = "serialize_nested_optional_secret")] pub oidc_client_secret: Option>, /// The new oidc response mode. @@ -346,9 +361,6 @@ pub struct IdentityProviderUpdate { } /// Identity provider create request. -/// -/// `PartialEq` is not derived because the embedded `IdentityProviderCreate` -/// carries a `SecretString`. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] @@ -359,9 +371,6 @@ pub struct IdentityProviderCreateRequest { } /// Identity provider update request. -/// -/// `PartialEq` is not derived because the embedded `IdentityProviderUpdate` -/// carries a `SecretString`. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] @@ -416,32 +425,36 @@ fn default_list_limit() -> Option { mod tests { use super::*; - /// The create DTO is serialized into OPA policy input - /// (`json!({"identity_provider": req.identity_provider})`). The client secret - /// must be redacted there. + /// The client secret must never leak through the DTO's `Debug` (the tracing + /// / logging vector), while remaining available via `expose_secret` for the + /// storage write. #[test] - fn idp_create_redacts_client_secret_for_policy() { + fn idp_create_debug_does_not_leak_client_secret() { + use secrecy::ExposeSecret; let create: IdentityProviderCreate = serde_json::from_str(r#"{"name":"idp","oidc_client_secret":"CSLEAK"}"#).unwrap(); - let rendered = serde_json::json!({ "identity_provider": &create }).to_string(); assert!( - !rendered.contains("CSLEAK"), - "leaked client secret: {rendered}" + !format!("{create:?}").contains("CSLEAK"), + "Debug leaked client secret: {create:?}" + ); + assert_eq!( + create + .oidc_client_secret + .as_ref() + .map(|s| s.expose_secret()), + Some("CSLEAK") ); - assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); } /// Same for the update DTO (nested `Option>`). #[test] - fn idp_update_redacts_client_secret_for_policy() { + fn idp_update_debug_does_not_leak_client_secret() { let update: IdentityProviderUpdate = serde_json::from_str(r#"{"oidc_client_secret":"CSLEAK2"}"#).unwrap(); - let rendered = serde_json::json!({ "identity_provider": &update }).to_string(); assert!( - !rendered.contains("CSLEAK2"), - "leaked client secret: {rendered}" + !format!("{update:?}").contains("CSLEAK2"), + "Debug leaked client secret: {update:?}" ); - assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); } /// Regression guard: the read/response DTO has no client-secret field, so a diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index 659b0246b..e9a72a79f 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -30,7 +30,6 @@ pub mod k8s_auth; pub mod scope; #[cfg(feature = "conv")] mod scope_conv; -pub(crate) mod secret_serde; pub mod trust; pub mod v3; pub mod v4; diff --git a/crates/api-types/src/secret_serde.rs b/crates/api-types/src/secret_serde.rs deleted file mode 100644 index 5ba2cc87a..000000000 --- a/crates/api-types/src/secret_serde.rs +++ /dev/null @@ -1,157 +0,0 @@ -// 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 -//! Serialization helpers for [`secrecy::SecretString`] fields. -//! -//! `SecretString` deliberately does not implement `Serialize`. These helpers -//! let DTOs that must remain serializable (e.g. for policy/audit payloads that -//! embed the request or resource) do so **without ever exposing the secret** — -//! a present secret is rendered as a fixed `"[REDACTED]"` marker so field -//! presence is preserved while the value never leaks. -//! -//! This is intentionally different from the "expose once" helper used for -//! secrets that are part of an API contract (e.g. a one-time API-key token): -//! those live next to their own DTO and call `expose_secret()` on purpose. - -use secrecy::SecretString; -use serde::Serializer; - -// NOTE: length/non-empty validation for wrapped secrets is deliberately NOT done -// with validator's field-level `custom` here: validator 0.20 unconditionally -// calls `ValidationError::add_param(&field)`, which requires the field to be -// `Serialize` — and `SecretString` intentionally is not. Password non-emptiness -// is instead enforced centrally in -// `openstack_keystone_config::SecurityComplianceProvider::validate_password`, -// which runs on the wrapped value at the service layer for every write path. - -/// Redact an `Option` on serialize. -pub(crate) fn serialize_secret_redacted( - secret: &Option, - serializer: S, -) -> Result -where - S: Serializer, -{ - match secret { - Some(_) => serializer.serialize_str("[REDACTED]"), - None => serializer.serialize_none(), - } -} - -/// Redact a required `SecretString` on serialize. -pub(crate) fn serialize_secret_redacted_required( - _secret: &SecretString, - serializer: S, -) -> Result -where - S: Serializer, -{ - serializer.serialize_str("[REDACTED]") -} - -/// Redact the nested `Option>` used by update DTOs, where -/// the outer `Option` is "present-in-request" and the inner is "set-or-clear". -pub(crate) fn serialize_secret_redacted_nested( - secret: &Option>, - serializer: S, -) -> Result -where - S: Serializer, -{ - match secret { - Some(Some(_)) => serializer.serialize_str("[REDACTED]"), - _ => serializer.serialize_none(), - } -} - -#[cfg(test)] -mod tests { - use secrecy::SecretString; - use serde::Serialize; - - use super::*; - - const SECRET: &str = "super-secret-value"; - - #[derive(Serialize)] - struct OptHolder { - #[serde(serialize_with = "serialize_secret_redacted")] - secret: Option, - } - - #[derive(Serialize)] - struct RequiredHolder { - #[serde(serialize_with = "serialize_secret_redacted_required")] - secret: SecretString, - } - - #[derive(Serialize)] - struct NestedHolder { - #[serde(serialize_with = "serialize_secret_redacted_nested")] - secret: Option>, - } - - #[test] - fn opt_secret_is_redacted_when_present() { - let json = serde_json::to_string(&OptHolder { - secret: Some(SecretString::from(SECRET)), - }) - .unwrap(); - assert!(!json.contains(SECRET), "secret leaked: {json}"); - assert!(json.contains("[REDACTED]"), "not redacted: {json}"); - } - - #[test] - fn opt_secret_is_null_when_absent() { - let json = serde_json::to_string(&OptHolder { secret: None }).unwrap(); - assert_eq!(json, r#"{"secret":null}"#); - } - - #[test] - fn required_secret_is_redacted() { - let json = serde_json::to_string(&RequiredHolder { - secret: SecretString::from(SECRET), - }) - .unwrap(); - assert!(!json.contains(SECRET), "secret leaked: {json}"); - assert!(json.contains("[REDACTED]"), "not redacted: {json}"); - } - - #[test] - fn nested_secret_is_redacted_only_when_set() { - // Present + set -> redacted. - let set = serde_json::to_string(&NestedHolder { - secret: Some(Some(SecretString::from(SECRET))), - }) - .unwrap(); - assert!(!set.contains(SECRET), "secret leaked: {set}"); - assert!(set.contains("[REDACTED]"), "not redacted: {set}"); - - // Explicit clear and absent both serialize to null (no value to leak). - assert_eq!( - serde_json::to_string(&NestedHolder { secret: Some(None) }).unwrap(), - r#"{"secret":null}"# - ); - assert_eq!( - serde_json::to_string(&NestedHolder { secret: None }).unwrap(), - r#"{"secret":null}"# - ); - } - - #[test] - fn debug_of_secret_does_not_leak() { - // secrecy's own Debug guarantee, pinned here as the core requirement. - let secret = SecretString::from(SECRET); - assert!(!format!("{secret:?}").contains(SECRET)); - } -} diff --git a/crates/api-types/src/v3/auth/token.rs b/crates/api-types/src/v3/auth/token.rs index 216eea009..c076ebba4 100644 --- a/crates/api-types/src/v3/auth/token.rs +++ b/crates/api-types/src/v3/auth/token.rs @@ -13,17 +13,26 @@ // SPDX-License-Identifier: Apache-2.0 use chrono::{DateTime, Utc}; -use secrecy::SecretString; -use serde::{Deserialize, Serialize}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize, Serializer}; #[cfg(feature = "validate")] use validator::Validate; use crate::catalog::*; use crate::scope::*; -use crate::secret_serde::serialize_secret_redacted_required; use crate::trust::TokenTrustRepr; use crate::v3::role::RoleRef; +/// Serialize a secret transparently for transport. These fields arrive in the +/// auth request body and must round-trip to the server; `SecretString` keeps +/// them out of `Debug`/logs, which is the exposure vector that matters. +fn serialize_secret_string(secret: &SecretString, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(secret.expose_secret()) +} + /// Authorization token. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[cfg_attr( @@ -133,9 +142,6 @@ pub struct TokenResponse { } /// An authentication request. -/// -/// `PartialEq` is not derived: the embedded identity carries a `SecretString` -/// password. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] @@ -146,9 +152,6 @@ pub struct AuthRequest { } /// An authentication request. -/// -/// `PartialEq` is not derived: the embedded identity carries a `SecretString` -/// password. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] @@ -172,8 +175,6 @@ pub struct AuthRequestInner { } /// An identity object. -/// -/// `PartialEq` is not derived: the embedded password carries a `SecretString`. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -208,9 +209,6 @@ pub struct Identity { } /// The password object, contains the authentication information. -/// -/// `PartialEq` is not derived: the embedded user carries a `SecretString` -/// password. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -229,9 +227,6 @@ pub struct PasswordAuth { } /// User password information. -/// -/// `PartialEq` is intentionally not derived: `password` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -256,15 +251,14 @@ pub struct UserPassword { #[cfg_attr(feature = "builder", builder(default))] #[cfg_attr(feature = "validate", validate(nested))] pub domain: Option, - /// User password. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing; redacted (never exposed) when serialized. + /// User password. #[cfg_attr(feature = "openapi", schema(value_type = String))] - #[serde(serialize_with = "serialize_secret_redacted_required")] + #[serde(serialize_with = "serialize_secret_string")] pub password: SecretString, } /// The TOTP object, contains the authentication information. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -282,7 +276,7 @@ pub struct TotpAuth { } /// User TOTP information. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -307,8 +301,9 @@ pub struct TotpUser { #[cfg_attr(feature = "validate", validate(nested))] pub domain: Option, /// The passcode generated by the user's TOTP device/app. - #[cfg_attr(feature = "validate", validate(length(max = 32)))] - pub passcode: String, + #[cfg_attr(feature = "openapi", schema(value_type = String))] + #[serde(serialize_with = "serialize_secret_string")] + pub passcode: SecretString, } /// User information. @@ -340,7 +335,7 @@ pub struct User { } /// The token object, contains the authentication information. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", derive(derive_builder::Builder), @@ -353,8 +348,9 @@ pub struct User { #[cfg_attr(feature = "validate", derive(validator::Validate))] pub struct TokenAuth { /// An authentication token. - #[cfg_attr(feature = "validate", validate(length(max = 1024)))] - pub id: String, + #[cfg_attr(feature = "openapi", schema(value_type = String))] + #[serde(serialize_with = "serialize_secret_string")] + pub id: SecretString, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -404,22 +400,25 @@ mod tests { const PWD: &str = "hunter2-plaintext"; #[test] - fn user_password_deserializes_plaintext_but_never_leaks() { - // Plaintext arrives on the wire and is accepted into the SecretString. + fn user_password_deserializes_plaintext_and_debug_never_leaks() { + // Plaintext arrives on the wire and is accepted into the secret field. let up: UserPassword = serde_json::from_str(&format!(r#"{{"name":"alice","password":"{PWD}"}}"#)).unwrap(); assert_eq!(up.password.expose_secret(), PWD); - // Debug must not leak (the #[instrument] / log vector). + // Debug (the #[instrument] / log vector) must not leak. assert!( !format!("{up:?}").contains(PWD), "Debug leaked the password" ); - // Serialization (e.g. into a policy/audit payload) must redact. + // Serialization is transparent: the auth body must carry the real + // password so the request round-trips to the server. let json = serde_json::to_string(&up).unwrap(); - assert!(!json.contains(PWD), "serialize leaked the password: {json}"); - assert!(json.contains("[REDACTED]"), "password not redacted: {json}"); + assert!( + json.contains(PWD), + "password not carried for transport: {json}" + ); } #[test] @@ -433,21 +432,17 @@ mod tests { } #[test] - fn full_auth_request_serialize_redacts_password_at_depth() { + fn full_auth_request_serialize_carries_password_for_transport() { // The password sits 4 levels deep (auth -> identity -> password -> user). - // Prove redaction survives the whole nested Serialize tree, via both - // `to_string` and `to_value`. + // Serialization must carry it through the whole nested tree so a client + // can send the request, via both `to_string` and `to_value`. let req = nested_auth_request(); let as_string = serde_json::to_string(&req).unwrap(); let as_value = serde_json::to_value(&req).unwrap().to_string(); for rendered in [as_string, as_value] { assert!( - !rendered.contains(PWD), - "serialize leaked password at depth: {rendered}" - ); - assert!( - rendered.contains("[REDACTED]"), - "nested password not redacted: {rendered}" + rendered.contains(PWD), + "password not carried for transport at depth: {rendered}" ); } } diff --git a/crates/api-types/src/v3/auth_conv.rs b/crates/api-types/src/v3/auth_conv.rs index bc8e9cdfb..b66cc1110 100644 --- a/crates/api-types/src/v3/auth_conv.rs +++ b/crates/api-types/src/v3/auth_conv.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 use openstack_keystone_core_types::error::BuilderError; +use secrecy::ExposeSecret; use crate::v3::auth::token as api_types; @@ -41,7 +42,7 @@ impl TryFrom } upa.domain(domain_builder.build()?); } - upa.password(value.password.clone()); + upa.password(value.password.expose_secret()); upa.build() } } diff --git a/crates/api-types/src/v3/user.rs b/crates/api-types/src/v3/user.rs index 1ea4a6ca9..1bb5336e1 100644 --- a/crates/api-types/src/v3/user.rs +++ b/crates/api-types/src/v3/user.rs @@ -14,13 +14,27 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; -use secrecy::SecretString; -use serde::{Deserialize, Serialize}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize, Serializer}; use serde_json::Value; #[cfg(feature = "validate")] use validator::Validate; -use crate::secret_serde::serialize_secret_redacted; +/// Serialize an optional password transparently for transport (the client must +/// send the real value). `SecretString` keeps it out of `Debug`/logs; the +/// response type carries no password field. +fn serialize_optional_secret( + secret: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + match secret { + Some(secret) => serializer.serialize_some(secret.expose_secret()), + None => serializer.serialize_none(), + } +} /// User response object. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -105,9 +119,6 @@ pub struct UserResponse { } /// Create user data. -/// -/// `PartialEq` is intentionally not derived: `password` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -160,25 +171,19 @@ pub struct UserCreate { #[cfg_attr(feature = "validate", validate(nested))] pub options: Option, - /// The password for the user. Wrapped in [`SecretString`] to prevent - /// accidental exposure via Debug/tracing; redacted (never exposed) when - /// serialized into policy/audit payloads. Non-emptiness and regex policy are - /// enforced on the wrapped value at the service layer via - /// `security_compliance.validate_password`. + /// The password for the user. Non-emptiness and regex policy are enforced at + /// the service layer via `security_compliance.validate_password`. #[cfg_attr(feature = "builder", builder(default))] #[cfg_attr(feature = "openapi", schema(value_type = Option))] #[serde( default, - serialize_with = "serialize_secret_redacted", - skip_serializing_if = "Option::is_none" + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_secret" )] pub password: Option, } /// Complete create user request. -/// -/// `PartialEq` is not derived because the embedded `UserCreate` carries a -/// `SecretString`. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] @@ -189,9 +194,6 @@ pub struct UserCreateRequest { } /// Update user data. -/// -/// `PartialEq` is intentionally not derived: `password` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr( feature = "builder", @@ -241,23 +243,18 @@ pub struct UserUpdate { #[cfg_attr(feature = "validate", validate(nested))] pub options: Option, - /// The password for the user. Wrapped in [`SecretString`] to prevent - /// accidental exposure via Debug/tracing; redacted (never exposed) when - /// serialized into policy/audit payloads. + /// The password for the user. #[cfg_attr(feature = "builder", builder(default))] #[cfg_attr(feature = "openapi", schema(value_type = Option))] #[serde( default, - serialize_with = "serialize_secret_redacted", - skip_serializing_if = "Option::is_none" + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_optional_secret" )] pub password: Option, } /// Complete update user request. -/// -/// `PartialEq` is not derived because the embedded `UserUpdate` carries a -/// `SecretString`. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] #[cfg_attr(feature = "validate", derive(validator::Validate))] @@ -350,12 +347,10 @@ mod tests { use super::*; /// Critical: `UserCreate` carries BOTH `#[serde(flatten)] extra` and a - /// `password` with a custom `serialize_with` + `skip_serializing_if`. This is - /// the exact struct the handler serializes into OPA policy input - /// (`json!({"user": req.user})`). Prove the flatten interaction does not - /// leak the password and does not drop `extra`. + /// `password`. Prove the flatten interaction round-trips the password for + /// transport and does not drop `extra`, while `Debug` never leaks the value. #[test] - fn usercreate_flatten_redacts_password_and_keeps_extra() { + fn usercreate_flatten_keeps_password_and_extra() { let uc: UserCreate = serde_json::from_str( r#"{"domain_id":"d","name":"alice","enabled":true, "password":"PWLEAK","x_custom":"xval","y_custom":"yval"}"#, @@ -371,37 +366,39 @@ mod tests { Some("xval") ); - // Both the raw serialize and the OPA-shaped `json!({"user": uc})` path. - let direct = serde_json::to_string(&uc).unwrap(); - let opa = serde_json::json!({ "user": &uc }).to_string(); - for rendered in [direct, opa] { - assert!( - !rendered.contains("PWLEAK"), - "flatten leaked password: {rendered}" - ); - assert!( - rendered.contains("[REDACTED]"), - "password not redacted: {rendered}" - ); - assert!( - rendered.contains("x_custom") && rendered.contains("xval"), - "extra dropped by flatten+redact: {rendered}" - ); - assert!( - rendered.contains("y_custom") && rendered.contains("yval"), - "extra dropped by flatten+redact: {rendered}" - ); - } + // Debug (the logging vector) must never reveal the password. + assert!( + !format!("{uc:?}").contains("PWLEAK"), + "Debug leaked password: {uc:?}" + ); + + // Serialization is transparent (the body must round-trip on the wire), + // and the flattened `extra` keys are preserved alongside `password`. + let rendered = serde_json::to_string(&uc).unwrap(); + assert!( + rendered.contains("PWLEAK"), + "password not carried for transport: {rendered}" + ); + assert!( + rendered.contains("x_custom") && rendered.contains("xval"), + "extra dropped by flatten: {rendered}" + ); + assert!( + rendered.contains("y_custom") && rendered.contains("yval"), + "extra dropped by flatten: {rendered}" + ); } - /// `UserUpdate` has the same flatten+redact shape. + /// `UserUpdate` has the same flatten shape. #[test] - fn userupdate_flatten_redacts_password_and_keeps_extra() { + fn userupdate_flatten_keeps_password_and_extra() { let uu: UserUpdate = serde_json::from_str(r#"{"password":"UPWLEAK","z_extra":"zz"}"#).unwrap(); - let rendered = serde_json::json!({ "user": &uu }).to_string(); - assert!(!rendered.contains("UPWLEAK"), "leaked: {rendered}"); - assert!(rendered.contains("[REDACTED]"), "not redacted: {rendered}"); + assert!( + !format!("{uu:?}").contains("UPWLEAK"), + "Debug leaked password: {uu:?}" + ); + let rendered = serde_json::to_string(&uu).unwrap(); assert!(rendered.contains("z_extra"), "extra dropped: {rendered}"); } diff --git a/crates/core-types/src/federation/identity_provider.rs b/crates/core-types/src/federation/identity_provider.rs index 88886d0ee..4b73dcb1d 100644 --- a/crates/core-types/src/federation/identity_provider.rs +++ b/crates/core-types/src/federation/identity_provider.rs @@ -14,31 +14,13 @@ //! # Federated identity provider types use derive_builder::Builder; -use secrecy::{ExposeSecret, SecretString}; -use serde::{Serialize, Serializer}; +use secrecy::SecretString; +use serde::Serialize; use serde_json::Value; use crate::error::BuilderError; -/// Serialize an optional secret as a fixed redaction marker so that it never -/// leaks in Debug/policy/audit payloads while still signalling presence. -fn serialize_secret_redacted( - secret: &Option, - serializer: S, -) -> Result -where - S: Serializer, -{ - match secret { - Some(_) => serializer.serialize_str("[REDACTED]"), - None => serializer.serialize_none(), - } -} - /// Identity provider resource. -/// -/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Builder, Clone, Debug, Default, Serialize)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] @@ -64,11 +46,10 @@ pub struct IdentityProvider { #[builder(default)] pub oidc_client_id: Option, - /// The OIDC client secret. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing; redacted (never exposed) when serialized into - /// policy/audit payloads. + /// The OIDC client secret. It is never returned back, so it is skipped on + /// serialization; `SecretString` additionally keeps it out of `Debug`. #[builder(default)] - #[serde(serialize_with = "serialize_secret_redacted")] + #[serde(skip_serializing)] pub oidc_client_secret: Option, #[builder(default)] @@ -104,41 +85,7 @@ pub struct IdentityProvider { pub provider_config: Option, } -/// Manual `PartialEq` (the derive cannot be used because `oidc_client_secret` -/// is a [`SecretString`], which does not implement `PartialEq`). Preserves the -/// pre-wrapping equality contract by comparing the exposed secret values. -impl PartialEq for IdentityProvider { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - && self.name == other.name - && self.domain_id == other.domain_id - && self.enabled == other.enabled - && self.oidc_discovery_url == other.oidc_discovery_url - && self.oidc_client_id == other.oidc_client_id - && self - .oidc_client_secret - .as_ref() - .map(ExposeSecret::expose_secret) - == other - .oidc_client_secret - .as_ref() - .map(ExposeSecret::expose_secret) - && self.oidc_response_mode == other.oidc_response_mode - && self.oidc_response_types == other.oidc_response_types - && self.jwks_url == other.jwks_url - && self.jwt_validation_pubkeys == other.jwt_validation_pubkeys - && self.bound_issuer == other.bound_issuer - && self.default_mapping_name == other.default_mapping_name - && self.oidc_scopes == other.oidc_scopes - && self.allowed_redirect_uris == other.allowed_redirect_uris - && self.provider_config == other.provider_config - } -} - /// New Identity provider data. -/// -/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Builder, Clone, Debug, Default)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] @@ -164,8 +111,7 @@ pub struct IdentityProviderCreate { #[builder(default)] pub oidc_client_id: Option, - /// The OIDC client secret. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing. + /// The OIDC client secret. #[builder(default)] pub oidc_client_secret: Option, @@ -200,9 +146,6 @@ pub struct IdentityProviderCreate { } /// Identity provider update data. -/// -/// `PartialEq` is intentionally not derived: `oidc_client_secret` is wrapped in -/// [`SecretString`], which does not implement `PartialEq` by design. #[derive(Builder, Clone, Debug, Default)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(into))] @@ -219,9 +162,8 @@ pub struct IdentityProviderUpdate { #[builder(default)] pub oidc_client_id: Option>, - /// The OIDC client secret. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing. Outer `Option` = present-in-request, - /// inner `Option` = set-or-clear. + /// The OIDC client secret. Outer `Option` = present-in-request, inner + /// `Option` = set-or-clear. #[builder(default)] pub oidc_client_secret: Option>, @@ -284,48 +226,33 @@ mod tests { const SECRET: &str = "oidc-top-secret"; #[test] - fn identity_provider_never_leaks_client_secret() { + fn identity_provider_debug_does_not_leak_client_secret() { let idp = IdentityProviderBuilder::default() .id("1") .name("idp") - .oidc_client_secret(SECRET) + .oidc_client_secret(SecretString::from(SECRET)) .build() .unwrap(); - // Debug (the #[instrument] / log vector) must not leak. + // Debug (the #[instrument] / log vector) must not leak the secret. assert!( !format!("{idp:?}").contains(SECRET), "Debug leaked client secret" ); - - // Serialization into the OPA policy / audit payload must redact. - let json = serde_json::to_string(&idp).unwrap(); - assert!( - !json.contains(SECRET), - "serialize leaked client secret: {json}" - ); - assert!( - json.contains("[REDACTED]"), - "client secret not redacted: {json}" - ); } #[test] - fn partial_eq_still_compares_client_secret() { - let base = IdentityProviderBuilder::default() - .id("1") - .name("idp") - .oidc_client_secret(SECRET) - .build() - .unwrap(); - let same = base.clone(); - let different = IdentityProviderBuilder::default() + fn identity_provider_does_not_serialize_client_secret() { + let idp = IdentityProviderBuilder::default() .id("1") .name("idp") - .oidc_client_secret("other-secret") + .oidc_client_secret(SecretString::from(SECRET)) .build() .unwrap(); - assert_eq!(base, same); - assert_ne!(base, different); + + // The secret is never returned back, so it must not appear on the wire. + let json = serde_json::to_string(&idp).unwrap(); + assert!(!json.contains(SECRET), "serialize leaked client secret"); + assert!(!json.contains("oidc_client_secret")); } } diff --git a/crates/core-types/src/identity/user.rs b/crates/core-types/src/identity/user.rs index 8b1e8a928..6a47c845d 100644 --- a/crates/core-types/src/identity/user.rs +++ b/crates/core-types/src/identity/user.rs @@ -129,10 +129,8 @@ pub struct UserCreate { #[validate(nested)] pub options: Option, - /// User password. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing. Non-emptiness and regex policy are enforced on - /// the wrapped value at the service layer via - /// `security_compliance.validate_password`. + /// User password. Non-emptiness and regex policy are enforced at the service + /// layer via `security_compliance.validate_password`. #[builder(default)] pub password: Option, } @@ -177,9 +175,8 @@ pub struct UserUpdate { #[validate(nested)] pub options: Option, - /// New user password. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing. Non-emptiness/policy enforced at the service - /// layer via `security_compliance.validate_password`. + /// New user password. Non-emptiness/policy enforced at the service layer via + /// `security_compliance.validate_password`. #[builder(default)] pub password: Option, } @@ -310,9 +307,8 @@ pub struct UserPasswordAuthRequest { #[validate(nested)] pub domain: Option, - /// User password. Wrapped in [`SecretString`] to prevent accidental - /// exposure via Debug/tracing. Required (no builder default: `SecretString` - /// does not implement `Default`). + /// User password. Required (no builder default: `SecretString` does not + /// implement `Default`). pub password: SecretString, } @@ -332,7 +328,10 @@ impl Default for UserPasswordAuthRequest { } /// User TOTP authentication request. -#[derive(Builder, Clone, Debug, Default, PartialEq, Validate)] +/// +/// `PartialEq`/`Default` are intentionally not derived: `passcode` is a required +/// [`SecretString`], which implements neither by design. +#[derive(Builder, Clone, Debug, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct UserTotpAuthRequest { @@ -352,9 +351,7 @@ pub struct UserTotpAuthRequest { pub domain: Option, /// The passcode generated by the user's TOTP device/app. - #[builder(default)] - #[validate(length(max = 32))] - pub passcode: String, + pub passcode: SecretString, } /// Domain information. diff --git a/crates/core/src/identity/service.rs b/crates/core/src/identity/service.rs index a30b2ac59..f7d70d8de 100644 --- a/crates/core/src/identity/service.rs +++ b/crates/core/src/identity/service.rs @@ -16,7 +16,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use secrecy::SecretString; +use secrecy::{ExposeSecret, SecretString}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::RwLock; @@ -438,7 +438,13 @@ impl IdentityApi for IdentityService { .and_then(serde_json::Value::as_u64) .map(|d| d as u32) .unwrap_or(30); - crate::credential::totp::verify_totp(seed, &auth.passcode, digits, period, now) + crate::credential::totp::verify_totp( + seed, + auth.passcode.expose_secret(), + digits, + period, + now, + ) }); if !matched { diff --git a/crates/federation-driver-sql/src/identity_provider/get.rs b/crates/federation-driver-sql/src/identity_provider/get.rs index 7a4a9f1f8..fd6ee66a9 100644 --- a/crates/federation-driver-sql/src/identity_provider/get.rs +++ b/crates/federation-driver-sql/src/identity_provider/get.rs @@ -59,15 +59,13 @@ mod tests { .append_query_results([vec![get_idp_mock("1")]]) .into_connection(); - assert_eq!( - get(&db, "1").await.unwrap().unwrap(), - IdentityProvider { - id: "1".into(), - name: "name".into(), - domain_id: Some("did".into()), - ..Default::default() - } - ); + // `IdentityProvider` holds an `oidc_client_secret: SecretString`, which + // is not `PartialEq`, so the returned value is asserted field by field. + let idp = get(&db, "1").await.unwrap().unwrap(); + assert_eq!(idp.id, "1"); + assert_eq!(idp.name, "name"); + assert_eq!(idp.domain_id, Some("did".into())); + assert!(idp.oidc_client_secret.is_none()); // Checking transaction log: single SELECT from the right table let txns = db.into_transaction_log(); diff --git a/crates/federation-driver-sql/src/identity_provider/list.rs b/crates/federation-driver-sql/src/identity_provider/list.rs index 06b307b19..192a730fc 100644 --- a/crates/federation-driver-sql/src/identity_provider/list.rs +++ b/crates/federation-driver-sql/src/identity_provider/list.rs @@ -145,17 +145,15 @@ mod tests { let db = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![get_idp_mock("1")]]) .into_connection(); - assert_eq!( - list(&db, &IdentityProviderListParameters::default()) - .await - .unwrap(), - vec![IdentityProvider { - id: "1".into(), - name: "name".into(), - domain_id: Some("did".into()), - ..Default::default() - }] - ); + // `IdentityProvider` is not `PartialEq` (secret field), so assert per + // field on the single returned entry. + let idps = list(&db, &IdentityProviderListParameters::default()) + .await + .unwrap(); + assert_eq!(idps.len(), 1); + assert_eq!(idps[0].id, "1"); + assert_eq!(idps[0].name, "name"); + assert_eq!(idps[0].domain_id, Some("did".into())); // Checking transaction log: single SELECT from the right table let txns = db.into_transaction_log(); @@ -172,25 +170,21 @@ mod tests { .append_query_results([vec![get_idp_mock("1")]]) .into_connection(); - assert_eq!( - list( - &db, - &IdentityProviderListParameters { - name: Some("idp_name".into()), - domain_ids: Some(HashSet::from([Some("did".into())])), - limit: Some(1), - marker: Some("marker".into()), - } - ) - .await - .unwrap(), - vec![IdentityProvider { - id: "1".into(), - name: "name".into(), - domain_id: Some("did".into()), - ..Default::default() - }] - ); + let idps = list( + &db, + &IdentityProviderListParameters { + name: Some("idp_name".into()), + domain_ids: Some(HashSet::from([Some("did".into())])), + limit: Some(1), + marker: Some("marker".into()), + }, + ) + .await + .unwrap(); + assert_eq!(idps.len(), 1); + assert_eq!(idps[0].id, "1"); + assert_eq!(idps[0].name, "name"); + assert_eq!(idps[0].domain_id, Some("did".into())); // Checking transaction log: single SELECT with correct filters let txns = db.into_transaction_log(); diff --git a/crates/keystone/src/api/v3/auth/token/common.rs b/crates/keystone/src/api/v3/auth/token/common.rs index 82338bf40..6df68257d 100644 --- a/crates/keystone/src/api/v3/auth/token/common.rs +++ b/crates/keystone/src/api/v3/auth/token/common.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 use openstack_keystone_core::auth::ExecutionContext; +use secrecy::ExposeSecret; use crate::api::error::KeystoneApiError; use crate::api::v3::auth::token::types::AuthRequest; @@ -60,7 +61,7 @@ pub(super) async fn authenticate_request( .get_token_provider() .authorize_by_token( &ExecutionContext::internal(state), - &token.id, + token.id.expose_secret(), Some(false), None, ) @@ -175,7 +176,7 @@ mod tests { identity_mock .expect_authenticate_by_totp() .withf(|_, req: &UserTotpAuthRequest| { - req.id == Some("uid".to_string()) && req.passcode == "123456" + req.id == Some("uid".to_string()) && req.passcode.expose_secret() == "123456" }) .returning(move |_, _| Ok(auth_clone.clone())); @@ -277,7 +278,7 @@ mod tests { methods: vec!["token".to_string()], password: None, token: Some(TokenAuth { - id: "fake_token".to_string() + id: "fake_token".into() }), totp: None, },