Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ pub const ROOT_TENANT_CANNOT_CHANGE_STATUS: &str = "ROOT_TENANT_CANNOT_CHANGE_ST
/// `provisioning_metadata.realm_name`) — see [`PROVISIONING_METADATA_FIELD`].
pub const IDP_INVALID_INPUT: &str = "IDP_INVALID_INPUT";

/// The `IdP` rejected the supplied password against its configured
/// password policy. Carried on the [`PASSWORD_FIELD`]
/// field-violation so clients can attribute the failure to the
/// password input; the raw policy text stays provider-side.
pub const PASSWORD_POLICY: &str = "PASSWORD_POLICY";

// ---------------------------------------------------------------------------
// `field_violations[].field` attribution keys.
//
Expand All @@ -69,6 +75,10 @@ pub const METADATA_FIELD: &str = "metadata";
/// [`ROOT_TENANT_CANNOT_CHANGE_STATUS`]).
pub const TENANT_ID_FIELD: &str = "tenant_id";

/// `password` field for `IdP` password-policy rejects (carries
/// [`PASSWORD_POLICY`]).
pub const PASSWORD_FIELD: &str = "password";

/// Shared fallback field for [`IDP_INVALID_INPUT`] when the `IdP`
/// plugin cannot localise the violation to a specific sub-key — the
/// public surface every `IdP` plugin shares.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -711,11 +711,78 @@ pub enum IdpUserOperationFailure {
/// `idp_unsupported_operation`. Providers MUST NOT silently no-op
/// a mutating call.
UnsupportedOperation { detail: String },
/// Provider returned a payload-rejection category (e.g. duplicate
/// username, validation failure on email format). AM maps this to
/// the canonical validation envelope; the catalog refinement is
/// owned by `feature-errors-observability`.
/// Provider returned a payload-rejection the plugin could NOT
/// classify further. AM maps this to the canonical validation
/// envelope with the generic `request`/`VALIDATION` tokens.
/// Providers SHOULD emit the classified variants below
/// ([`Self::DuplicateUser`], [`Self::PasswordPolicy`]) whenever
/// the vendor response identifies the cause — this catch-all is
/// the fallback for genuinely unattributable rejections.
Rejected { detail: String },
/// Provider rejected the operation because a uniqueness invariant
/// on `field` is already taken (duplicate username in
/// the realm, duplicate email realm-wide). AM maps this to the
/// canonical `already_exists` envelope (HTTP 409) — the create
/// contract already declares a Conflict response and documents
/// idempotency keyed by `(tenant_id, username)`.
DuplicateUser {
field: IdpUserDuplicateField,
detail: String,
},
/// Provider rejected the supplied password against its configured
/// password policy (length / complexity / history). AM maps this
/// to the canonical validation envelope with the structured
/// `password` / `PASSWORD_POLICY` field-violation tokens so
/// clients can attribute the failure to the password field
///; the raw policy text stays provider-side.
PasswordPolicy { detail: String },
}

/// Which unique user attribute an [`IdpUserOperationFailure::DuplicateUser`]
/// rejection collided on. `#[non_exhaustive]`: providers may learn to
/// classify further unique attributes without a breaking SDK release.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IdpUserDuplicateField {
/// Realm-scoped username collision.
Username,
/// Realm-wide email collision.
Email,
/// The provider reported a uniqueness conflict without an
/// attributable single field. Keycloak's `ModelDuplicateException`
/// path emits the combined constant "User exists with same
/// username or email" — on KC 26 that is the ONLY 409 shape
/// `createUser` produces directly — and a conflict body the plugin
/// cannot parse lands here too: on the user-create endpoint a 409
/// has no other cause than a uniqueness collision, so the status
/// alone justifies the conflict classification even when the field
/// stays unknown.
UsernameOrEmail,
}

impl IdpUserDuplicateField {
/// Stable wire/field token for canonical `field_violations.field`
/// / `resource_name` and metric labels.
#[must_use]
pub const fn as_field_token(self) -> &'static str {
match self {
Self::Username => "username",
Self::Email => "email",
Self::UsernameOrEmail => "username_or_email",
}
}

/// Human phrase for curated public error details ("a user with
/// this {phrase} already exists"). Centralised here so AM's
/// canonical boundary and its tests cannot drift.
#[must_use]
pub const fn as_human_phrase(self) -> &'static str {
match self {
Self::Username => "username",
Self::Email => "email",
Self::UsernameOrEmail => "username or email",
}
}
}

impl IdpUserOperationFailure {
Expand All @@ -731,6 +798,8 @@ impl IdpUserOperationFailure {
Self::Unavailable { .. } => "unavailable",
Self::UnsupportedOperation { .. } => "unsupported_operation",
Self::Rejected { .. } => "rejected",
Self::DuplicateUser { .. } => "duplicate_user",
Self::PasswordPolicy { .. } => "password_policy",
}
}

Expand All @@ -743,7 +812,9 @@ impl IdpUserOperationFailure {
match self {
Self::Unavailable { detail }
| Self::UnsupportedOperation { detail }
| Self::Rejected { detail } => detail,
| Self::Rejected { detail }
| Self::DuplicateUser { detail, .. }
| Self::PasswordPolicy { detail } => detail,
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ pub use idp::{
};
pub use idp_user::{
IdpDeprovisionUserRequest, IdpListUsersRequest, IdpNewUser, IdpProvisionUserRequest,
IdpTenantContext, IdpUser, IdpUserFilterField, IdpUserOperationFailure, IdpUserPagination,
IdpUserPaginationError, IdpUserQuery, ListUsersQuery, NewUserPassword,
IdpTenantContext, IdpUser, IdpUserDuplicateField, IdpUserFilterField, IdpUserOperationFailure,
IdpUserPagination, IdpUserPaginationError, IdpUserQuery, ListUsersQuery, NewUserPassword,
};
pub use metadata::{
MetadataEntry, MetadataEntryFilterField, MetadataEntryQuery, UpsertMetadataRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,11 @@ pub struct TenantInfoQuery {
/// (the serde rename on the SDK enum). The `OData` parser only
/// validates the value is a String; arbitrary unknown values
/// (including the AM-internal `"provisioning"`) reach storage and
/// are mapped to a domain error downstream.
/// are mapped to a domain error downstream. `$orderby=status` is
/// supported and sorts by the lifecycle ordinal (`active` <
/// `suspended` < `deleted`), not alphabetically — operator UIs sort
/// their Status column with it; filter comparisons stay
/// membership-only (`eq` / `ne` / `in`).
#[odata(filter(kind = "String"))]
pub status: String,
/// Deterministic `UUIDv5` of the registered tenant-type schema id.
Expand All @@ -259,7 +263,7 @@ pub struct TenantInfoQuery {
/// string is mapped server-side to its deterministic `UUIDv5` and
/// compared against `tenant_type_uuid`; ordered operators and
/// `$orderby` are rejected (sorting by a derived UUID is
/// meaningless), mirroring `status`.
/// meaningless).
#[odata(filter(kind = "String"))]
pub tenant_type: String,
/// `tenants.self_managed` flag. Useful to surface "boundary"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,35 @@ pub enum DomainError {
#[error("already exists: {detail}")]
AlreadyExists { detail: String },

/// The `IdP` reported a uniqueness collision on a user attribute
/// during a user operation (duplicate username in the
/// realm, duplicate email realm-wide, or KC's combined
/// "username or email" constant). Distinct from
/// [`Self::AlreadyExists`] (tenant-flavoured, DB-classifier
/// produced) so the canonical envelope rides the `user` resource
/// type. Carries the typed colliding field — the boundary mapping
/// derives both the stable `resource_name` token and the curated
/// public detail from it, so the wording lives in exactly one
/// place and no caller can pair an inconsistent detail/resource.
/// The caller-supplied value is never echoed (redaction posture of
/// the `IdP` boundary). Surfaces as HTTP 409 `already_exists`.
#[error("user already exists ({})", field.as_field_token())]
UserAlreadyExists {
field: account_management_sdk::IdpUserDuplicateField,
},

/// The `IdP` rejected the supplied password against its configured
/// password policy. Distinct from [`Self::Validation`]
/// so the canonical envelope carries the structured
/// `password` / `PASSWORD_POLICY` field-violation tokens instead
/// of the generic `request` / `VALIDATION` pair — clients can
/// attribute the failure to the password input. The raw policy
/// text is redacted upstream (digest in `am.idp` logs); `detail`
/// is the curated public summary. Surfaces as HTTP 400
/// `invalid_argument`.
#[error("password rejected by IdP policy: {detail}")]
IdpPasswordPolicy { detail: String },

// ---- Aborted (HTTP 409) ----
/// Retry-budget-exhausted serialization failure surfaced by
/// [`crate::infra::storage::repo_impl::helpers::with_serializable_retry`]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ impl DomainError {
Self::MetadataEntryNotFound { .. } => "metadata_entry_not_found",
Self::MetadataVersionMismatch { .. } => "metadata_version_mismatch",
Self::AlreadyExists { .. } => "already_exists",
Self::UserAlreadyExists { .. } => "user_already_exists",
Self::IdpPasswordPolicy { .. } => "idp_password_policy",
Self::Aborted { .. } => "aborted",
Self::TypeNotAllowed { .. } => "type_not_allowed",
Self::TenantDepthExceeded { .. } => "tenant_depth_exceeded",
Expand Down Expand Up @@ -365,12 +367,14 @@ impl DomainError {
| Self::PendingExists { .. }
| Self::AlreadyResolved
| Self::Conflict { .. }
| Self::IdpPasswordPolicy { .. }
| Self::FeatureDisabled { .. } => 400,
Self::NotFound { .. }
| Self::UserNotFound { .. }
| Self::ConversionRequestNotFound { .. }
| Self::MetadataEntryNotFound { .. } => 404,
Self::AlreadyExists { .. }
| Self::UserAlreadyExists { .. }
| Self::Aborted { .. }
| Self::MetadataVersionMismatch { .. } => 409,
Self::CrossTenantDenied { .. } => 403,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,101 @@ fn provision_ambiguous_chains_redacted_cause_without_leaking_raw_detail() {
"DomainError::Internal::cause MUST be reachable via Error::source"
);
}

// ---------------------------------------------------------------------------
// Classified user-operation rejections.
// ---------------------------------------------------------------------------

#[test]
fn user_duplicate_username_maps_to_user_already_exists() {
let err = IdpUserOperationFailure::DuplicateUser {
field: account_management_sdk::IdpUserDuplicateField::Username,
detail: "User exists with same username".into(),
}
.into_domain_error(fixture_tenant_id());
// The typed field survives verbatim; the curated public wording is
// derived (once) at the canonical boundary, so raw provider text
// cannot leak from here by construction.
assert!(
matches!(
err,
DomainError::UserAlreadyExists {
field: account_management_sdk::IdpUserDuplicateField::Username
}
),
"expected UserAlreadyExists(Username), got {err:?}"
);
}

#[test]
fn user_duplicate_email_maps_to_user_already_exists() {
let err = IdpUserOperationFailure::DuplicateUser {
field: account_management_sdk::IdpUserDuplicateField::Email,
detail: "User exists with same email".into(),
}
.into_domain_error(fixture_tenant_id());
assert!(
matches!(
err,
DomainError::UserAlreadyExists {
field: account_management_sdk::IdpUserDuplicateField::Email
}
),
"expected UserAlreadyExists(Email), got {err:?}"
);
}

// KC's ModelDuplicateException path emits the combined constant "User
// exists with same username or email" (on KC 26 it is the only 409
// `createUser` produces directly) — the unattributable classification
// must survive to the domain error, not collapse into Username.
#[test]
fn user_duplicate_combined_maps_to_username_or_email() {
let err = IdpUserOperationFailure::DuplicateUser {
field: account_management_sdk::IdpUserDuplicateField::UsernameOrEmail,
detail: "User exists with same username or email".into(),
}
.into_domain_error(fixture_tenant_id());
assert!(
matches!(
err,
DomainError::UserAlreadyExists {
field: account_management_sdk::IdpUserDuplicateField::UsernameOrEmail
}
),
"expected UserAlreadyExists(UsernameOrEmail), got {err:?}"
);
}

#[test]
fn user_password_policy_maps_to_structured_password_violation() {
let err = IdpUserOperationFailure::PasswordPolicy {
detail: "invalidPasswordMinLengthMessage: 12".into(),
}
.into_domain_error(fixture_tenant_id());
let DomainError::IdpPasswordPolicy { detail } = err else {
panic!("expected IdpPasswordPolicy, got a different variant");
};
// Curated public summary only — the raw KC policy text stays in
// the digest-only log line.
assert!(
!detail.contains("invalidPasswordMinLengthMessage"),
"raw provider policy text leaked into public detail: {detail}"
);
}

// The unclassified catch-all keeps its historical shape: a provider
// rejection the plugin could not attribute still collapses to the
// redacted generic Validation (no accidental behavior change for
// legacy plugins that only emit `Rejected`).
#[test]
fn user_rejected_still_maps_to_redacted_validation() {
let err = IdpUserOperationFailure::Rejected {
detail: "something vendor-specific".into(),
}
.into_domain_error(fixture_tenant_id());
let DomainError::Validation { detail } = err else {
panic!("expected Validation, got a different variant");
};
assert!(detail.contains("detail redacted"), "got: {detail}");
}
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,42 @@ impl UserOperationFailureExt for IdpUserOperationFailure {
}
// @cpt-end:cpt-cf-account-management-algo-idp-user-operations-contract-idp-contract-invocation:p1:inst-algo-ici-provider-error-return
// @cpt-end:cpt-cf-account-management-algo-idp-user-operations-contract-idp-contract-invocation:p1:inst-algo-ici-provider-error
// Classified uniqueness collision: surface HTTP
// 409 `already_exists` with the stable colliding-field
// token, instead of collapsing into the redacted generic
// Validation. The provider detail is still digest-only in
// logs — the public detail is derived from the typed field
// at the canonical boundary.
Self::DuplicateUser { field, detail } => {
let (digest, len) = redact_provider_detail(&detail);
tracing::warn!(
target: "am.idp",
tenant_id = %tenant_id,
field = field.as_field_token(),
provider_detail_digest = digest,
provider_detail_len = len,
"IdP user operation DuplicateUser; surfacing already_exists, raw detail redacted"
);
DomainError::UserAlreadyExists { field }
}
// Classified password-policy reject: surface
// the structured `password` / `PASSWORD_POLICY` violation
// instead of the generic `request` / `VALIDATION`. Raw
// policy text stays digest-only in logs.
Self::PasswordPolicy { detail } => {
let (digest, len) = redact_provider_detail(&detail);
tracing::warn!(
target: "am.idp",
tenant_id = %tenant_id,
provider_detail_digest = digest,
provider_detail_len = len,
"IdP user operation PasswordPolicy; surfacing password field violation, raw detail redacted"
);
DomainError::IdpPasswordPolicy {
detail: "the supplied password does not meet the identity provider's password policy"
.to_owned(),
}
}
// SDK enum is `#[non_exhaustive]`. A new variant added in
// a future SDK release lands here until the AM-side
// mapping is updated; surface as `Internal` with a loud
Expand Down
Loading
Loading