From 6bbcec4aaf82c24a313541cc93f176786ce08f36 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Mon, 6 Jul 2026 09:17:23 +0200 Subject: [PATCH] feat(scim): ADR 0024 - Phase 1+2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the realm-registration layer for SCIM v2 resource provisioning, the first of six planned phases, plus the first fully working SCIM resource: POST/GET/PUT/DELETE /SCIM/v2/{domain_id}/Users, backed by real Identity users. - New core type `ScimRealmResource` (+ Create/Update/ListParameters) and a `ScimRealmProviderError` enum, following the ApiClientResource pattern. `idp_id` is mandatory and validated against a real `IdentityProvider` at realm create/update. - New `openstack-keystone-scim-driver-raft` crate storing realms at `scim_realm:v1::`. - New `ScimRealmApi`/`ScimRealmService` provider (ExecutionContext-based, so realm CRUD gets CADF audit trail parity with other v4 admin resources) wired through `PluginManagerApi`/`Provider`/`Config`. - New `/v4/scim-realms` CRUD API with OPA policies under `identity/scim_realm/*`, requiring a `manager` (or `admin`) role. - `ScimRealmAuth` extractor (ADR 0024 §2.C amendment to ADR 0021 §3 Step 4): wraps the API-Key ingress pipeline, enforcing Domain-only scope and the Realm Activation Gate (403 if missing/disabled) before any SCIM resource handler runs. - Mapping Engine write-time constraint (ADR 0024 §2.C): rejects `Authorization::Project` rules in a ruleset whose `provider_id` has an active SCIM realm, with 422 Unprocessable Entity, mirroring the existing `is_system` defense-in-depth check. - `ScimResourceIndex` core type + scim-driver-raft storage, with an atomic realm-scoped externalId claim (create_if_absent) closing the TOCTOU race for ADR 0024 §3.D. - Domain-wide case-insensitive userName collision check (`IdentityApi::find_user_by_name_ci`) against local_user/nonlocal_user. - ScimRealmAuth-gated Users handlers implementing the Ownership Fencing Algorithm (§3.C), soft-disable delete with token revocation + CADF disable event (§6.A), and a SCIM-shaped 409 uniqueness error envelope. - Fixed a latent bug in `resolve_verified_api_client`: it extracted `Path` for domain_id, which only works when the route has exactly one dynamic segment; the new /Users/{id} routes have two and would have failed every request with 401. - OPA policies under `identity/scim/user/{create,list,show,update,delete}` plus their `_test.rego` pairs. SCIM provisioning and federation JIT login used to create separate `user` rows for the same real person: SCIM always wrote a `local_user` row while federation kept its own `federated_user` link, with nothing correlating the two. Fixed by matching python-keystone's own shadow-user scheme instead of inventing a new one: - `generate_public_id(domain_id, local_id, entity_type)` (core::identity) is bit-compatible with python-keystone's `id_generators.sha256.Generator.generate_public_ID`: sha256 over domain_id+entity_type+local_id in that order, no `idp_id` mixed in. This deliberately inherits upstream's own accepted collision risk when multiple IdPs share a domain and reuse the same subject value, rather than diverging from the reference formula. - `UserCreate.user_type` (Local/NonLocal) drives `identity-driver-sql` to create a `nonlocal_user` row instead of `local_user` for externally-managed identities; setting a password on a NonLocal user is a hard `Conflict`. - SCIM user create requires `externalId`, derives the user id from it deterministically, and provisions as NonLocal. - Federation JIT (`find_or_create_federated_user`) checks for a user at that same deterministic id before its legacy `federated_user` lookup/create fallback, and now assigns that id itself when creating a brand-new federated user too — so convergence holds regardless of which side (SCIM or federation) provisions the person first. - SCIM create pre-checks for an existing user at the derived id and returns a clean 409 (`scimType: uniqueness`) instead of letting a primary-key collision surface as an opaque driver error. Assisted-By: Claude Sonnet 5 Signed-off-by: Artem Goncharov --- Cargo.lock | 15 + Cargo.toml | 2 + crates/api-types/src/error.rs | 7 + crates/api-types/src/error_conv.rs | 68 + crates/api-types/src/v3/user_conv.rs | 1 + crates/api-types/src/v4.rs | 3 + crates/api-types/src/v4/scim_realm.rs | 151 ++ crates/api-types/src/v4/scim_realm_conv.rs | 68 + crates/config/src/lib.rs | 12 + crates/config/src/scim_realm.rs | 32 + crates/config/src/scim_resource.rs | 32 + crates/core-types/src/error.rs | 17 + crates/core-types/src/events.rs | 5 + crates/core-types/src/identity/user.rs | 8 + crates/core-types/src/lib.rs | 1 + crates/core-types/src/mapping/error.rs | 19 + crates/core-types/src/scim.rs | 22 + crates/core-types/src/scim/error.rs | 153 ++ crates/core-types/src/scim/index.rs | 144 ++ crates/core-types/src/scim/resource.rs | 125 ++ crates/core/src/api/api_key_auth.rs | 368 +++-- crates/core/src/cadf_hook.rs | 3 + crates/core/src/identity/backend.rs | 15 + crates/core/src/identity/mod.rs | 2 + crates/core/src/identity/provider_api.rs | 15 + crates/core/src/identity/service.rs | 11 + crates/core/src/identity/shadow_id.rs | 83 ++ crates/core/src/lib.rs | 2 + crates/core/src/mapping/service.rs | 727 ++++++++++ crates/core/src/mocks.rs | 108 ++ crates/core/src/plugin_manager.rs | 52 + crates/core/src/provider.rs | 44 + crates/core/src/scim_realm/backend.rs | 58 + crates/core/src/scim_realm/error.rs | 15 + crates/core/src/scim_realm/mod.rs | 30 + crates/core/src/scim_realm/provider_api.rs | 58 + crates/core/src/scim_realm/service.rs | 149 ++ crates/core/src/scim_resource/backend.rs | 77 + crates/core/src/scim_resource/error.rs | 15 + crates/core/src/scim_resource/mod.rs | 28 + crates/core/src/scim_resource/provider_api.rs | 78 ++ crates/core/src/scim_resource/service.rs | 136 ++ crates/identity-driver-sql/src/lib.rs | 17 + crates/identity-driver-sql/src/user.rs | 2 + crates/identity-driver-sql/src/user/create.rs | 52 +- .../src/user/find_by_name.rs | 110 ++ crates/key-repository/src/lib.rs | 12 +- crates/keystone/Cargo.toml | 1 + crates/keystone/src/api/v4/mod.rs | 3 + crates/keystone/src/api/v4/scim_realm.rs | 43 + .../keystone/src/api/v4/scim_realm/create.rs | 245 ++++ crates/keystone/src/api/v4/scim_realm/list.rs | 194 +++ crates/keystone/src/api/v4/scim_realm/show.rs | 200 +++ .../keystone/src/api/v4/scim_realm/update.rs | 260 ++++ crates/keystone/src/audit.rs | 1 + crates/keystone/src/lib.rs | 2 + crates/keystone/src/plugin_manager.rs | 78 ++ crates/keystone/src/scim.rs | 8 +- crates/keystone/src/scim/error.rs | 83 ++ crates/keystone/src/scim/types.rs | 244 ++++ crates/keystone/src/scim/user.rs | 34 + crates/keystone/src/scim/user/create.rs | 431 ++++++ crates/keystone/src/scim/user/delete.rs | 341 +++++ crates/keystone/src/scim/user/list.rs | 216 +++ crates/keystone/src/scim/user/show.rs | 259 ++++ crates/keystone/src/scim/user/update.rs | 337 +++++ crates/keystone/src/scim_realm.rs | 16 + crates/keystone/src/scim_resource.rs | 16 + crates/scim-driver-raft/Cargo.toml | 27 + crates/scim-driver-raft/src/lib.rs | 1247 +++++++++++++++++ doc/src/adr/0020-mapping-engine.md | 12 + doc/src/adr/0021-api-key-scim.md | 15 +- doc/src/adr/0024-scim-v2-provisioning.md | 38 +- policy/identity.rego | 20 + policy/identity/scim/user/create.rego | 53 + policy/identity/scim/user/create_test.rego | 17 + policy/identity/scim/user/delete.rego | 44 + policy/identity/scim/user/delete_test.rego | 15 + policy/identity/scim/user/list.rego | 43 + policy/identity/scim/user/list_test.rego | 15 + policy/identity/scim/user/show.rego | 44 + policy/identity/scim/user/show_test.rego | 15 + policy/identity/scim/user/update.rego | 44 + policy/identity/scim/user/update_test.rego | 15 + policy/identity/scim_realm/create.rego | 45 + policy/identity/scim_realm/create_test.rego | 16 + policy/identity/scim_realm/disable.rego | 41 + policy/identity/scim_realm/disable_test.rego | 16 + policy/identity/scim_realm/list.rego | 40 + policy/identity/scim_realm/list_test.rego | 16 + policy/identity/scim_realm/show.rego | 42 + policy/identity/scim_realm/show_test.rego | 16 + tests/integration/src/api_key/ingress.rs | 14 +- tests/integration/src/mapping/spiffe.rs | 10 +- 94 files changed, 7977 insertions(+), 107 deletions(-) create mode 100644 crates/api-types/src/v4/scim_realm.rs create mode 100644 crates/api-types/src/v4/scim_realm_conv.rs create mode 100644 crates/config/src/scim_realm.rs create mode 100644 crates/config/src/scim_resource.rs create mode 100644 crates/core-types/src/scim.rs create mode 100644 crates/core-types/src/scim/error.rs create mode 100644 crates/core-types/src/scim/index.rs create mode 100644 crates/core-types/src/scim/resource.rs create mode 100644 crates/core/src/identity/shadow_id.rs create mode 100644 crates/core/src/scim_realm/backend.rs create mode 100644 crates/core/src/scim_realm/error.rs create mode 100644 crates/core/src/scim_realm/mod.rs create mode 100644 crates/core/src/scim_realm/provider_api.rs create mode 100644 crates/core/src/scim_realm/service.rs create mode 100644 crates/core/src/scim_resource/backend.rs create mode 100644 crates/core/src/scim_resource/error.rs create mode 100644 crates/core/src/scim_resource/mod.rs create mode 100644 crates/core/src/scim_resource/provider_api.rs create mode 100644 crates/core/src/scim_resource/service.rs create mode 100644 crates/identity-driver-sql/src/user/find_by_name.rs create mode 100644 crates/keystone/src/api/v4/scim_realm.rs create mode 100644 crates/keystone/src/api/v4/scim_realm/create.rs create mode 100644 crates/keystone/src/api/v4/scim_realm/list.rs create mode 100644 crates/keystone/src/api/v4/scim_realm/show.rs create mode 100644 crates/keystone/src/api/v4/scim_realm/update.rs create mode 100644 crates/keystone/src/scim/error.rs create mode 100644 crates/keystone/src/scim/types.rs create mode 100644 crates/keystone/src/scim/user.rs create mode 100644 crates/keystone/src/scim/user/create.rs create mode 100644 crates/keystone/src/scim/user/delete.rs create mode 100644 crates/keystone/src/scim/user/list.rs create mode 100644 crates/keystone/src/scim/user/show.rs create mode 100644 crates/keystone/src/scim/user/update.rs create mode 100644 crates/keystone/src/scim_realm.rs create mode 100644 crates/keystone/src/scim_resource.rs create mode 100644 crates/scim-driver-raft/Cargo.toml create mode 100644 crates/scim-driver-raft/src/lib.rs create mode 100644 policy/identity/scim/user/create.rego create mode 100644 policy/identity/scim/user/create_test.rego create mode 100644 policy/identity/scim/user/delete.rego create mode 100644 policy/identity/scim/user/delete_test.rego create mode 100644 policy/identity/scim/user/list.rego create mode 100644 policy/identity/scim/user/list_test.rego create mode 100644 policy/identity/scim/user/show.rego create mode 100644 policy/identity/scim/user/show_test.rego create mode 100644 policy/identity/scim/user/update.rego create mode 100644 policy/identity/scim/user/update_test.rego create mode 100644 policy/identity/scim_realm/create.rego create mode 100644 policy/identity/scim_realm/create_test.rego create mode 100644 policy/identity/scim_realm/disable.rego create mode 100644 policy/identity/scim_realm/disable_test.rego create mode 100644 policy/identity/scim_realm/list.rego create mode 100644 policy/identity/scim_realm/list_test.rego create mode 100644 policy/identity/scim_realm/show.rego create mode 100644 policy/identity/scim_realm/show_test.rego diff --git a/Cargo.lock b/Cargo.lock index 1e8c6547d..d81c9a5d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4471,6 +4471,7 @@ dependencies = [ "openstack-keystone-resource-driver-sql", "openstack-keystone-revoke-driver-sql", "openstack-keystone-role-driver-sql", + "openstack-keystone-scim-driver-raft", "openstack-keystone-storage-api", "openstack-keystone-token-driver-fernet", "openstack-keystone-token-restriction-driver-sql", @@ -5007,6 +5008,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "openstack-keystone-scim-driver-raft" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "openstack-keystone-core", + "openstack-keystone-core-types", + "openstack-keystone-distributed-storage", + "rmp-serde", + "tokio", + "tracing", +] + [[package]] name = "openstack-keystone-storage-api" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e03827cb1..87545da67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/resource-driver-sql", "crates/role-driver-sql", "crates/revoke-driver-sql", + "crates/scim-driver-raft", "crates/storage", "crates/storage-api", "crates/storage-crypto", @@ -127,6 +128,7 @@ openstack-keystone-idmapping-driver-sql = { version = "0.1", path = "crates/idma openstack-keystone-resource-driver-sql = { version = "0.1", path = "crates/resource-driver-sql/" } openstack-keystone-revoke-driver-sql = { version = "0.1", path = "crates/revoke-driver-sql/" } openstack-keystone-role-driver-sql = { version = "0.1", path = "crates/role-driver-sql/" } +openstack-keystone-scim-driver-raft = { version = "0.1", path = "crates/scim-driver-raft/" } openstack-keystone-token-driver-fernet = { version = "0.1.0", path = "crates/token-driver-fernet" } openstack-keystone-token-restriction-driver-sql = { version = "0.1", path = "crates/token-restriction-driver-sql/" } openstack-keystone-trust-driver-sql = { version = "0.1", path = "crates/trust-driver-sql/" } diff --git a/crates/api-types/src/error.rs b/crates/api-types/src/error.rs index cfc788915..00ee64617 100644 --- a/crates/api-types/src/error.rs +++ b/crates/api-types/src/error.rs @@ -89,6 +89,13 @@ pub enum KeystoneApiError { #[error("rate limit exceeded, retry later")] TooManyRequests, + /// Request is syntactically valid but semantically invalid (RFC 4918 + /// §11.2 / RFC 7231 §6.5.1's `422` successor). Used for write-time + /// validation failures that are not simple malformed-request errors + /// (e.g. ADR 0024 §2.C's `Authorization::Project` prohibition). + #[error("{0}.")] + UnprocessableEntity(String), + #[error("The request you have made requires authentication.")] UnauthorizedNoContext, diff --git a/crates/api-types/src/error_conv.rs b/crates/api-types/src/error_conv.rs index 6d2c36169..3bc40dc04 100644 --- a/crates/api-types/src/error_conv.rs +++ b/crates/api-types/src/error_conv.rs @@ -35,6 +35,7 @@ use openstack_keystone_core_types::mapping::MappingProviderError; use openstack_keystone_core_types::resource::ResourceProviderError; use openstack_keystone_core_types::revoke::RevokeProviderError; use openstack_keystone_core_types::role::RoleProviderError; +use openstack_keystone_core_types::scim::{ScimRealmProviderError, ScimResourceProviderError}; use openstack_keystone_core_types::token::TokenProviderError; use crate::error::KeystoneApiError; @@ -55,6 +56,7 @@ impl IntoResponse for KeystoneApiError { StatusCode::INTERNAL_SERVER_ERROR } KeystoneApiError::TooManyRequests => StatusCode::TOO_MANY_REQUESTS, + KeystoneApiError::UnprocessableEntity(_) => StatusCode::UNPROCESSABLE_ENTITY, _ => StatusCode::BAD_REQUEST, }; @@ -328,6 +330,14 @@ impl From for KeystoneApiError { MappingProviderError::ApiClientNonDomainScopeForbidden(x) => Self::BadRequest(format!( "rule '{x}' grants a non-domain scope, which is forbidden for API Key (ApiClient) mapping rulesets (only domain scope is accepted)" )), + MappingProviderError::ScimRealmProjectScopeForbidden(x) => { + Self::UnprocessableEntity(format!( + "rule '{x}' grants project scope, which is forbidden for a mapping ruleset bound to an active SCIM realm" + )) + } + MappingProviderError::RoleNotFound(x) => Self::UnprocessableEntity(format!( + "rule references role '{x}' which does not exist" + )), MappingProviderError::RaftNotAvailable => Self::InternalError( "raft storage is not available in the mapping provider".to_string(), ), @@ -349,6 +359,64 @@ impl From for KeystoneApiError { } } +impl From for KeystoneApiError { + fn from(value: ScimRealmProviderError) -> Self { + match value { + ScimRealmProviderError::NotFound(x) => Self::NotFound { + resource: "scim_realm".into(), + identifier: x, + }, + ScimRealmProviderError::Conflict(x) => Self::Conflict(x), + ScimRealmProviderError::RaftNotAvailable => Self::InternalError( + "raft storage is not available in the scim_realm provider".to_string(), + ), + ScimRealmProviderError::RaftStoreError { source } => { + Self::InternalError(format!("raft storage error: {source}")) + } + ScimRealmProviderError::Driver { source } => { + Self::InternalError(format!("backend driver error: {source}")) + } + ScimRealmProviderError::UnsupportedDriver(x) => { + Self::InternalError(format!("unsupported driver `{x}`")) + } + ScimRealmProviderError::StructBuilder(e) => { + Self::InternalError(format!("structure builder error: {e}")) + } + // ScimRealmProviderError is non-exhaustive; catch any future variants + _ => Self::InternalError(value.to_string()), + } + } +} + +impl From for KeystoneApiError { + fn from(value: ScimResourceProviderError) -> Self { + match value { + ScimResourceProviderError::NotFound(x) => Self::NotFound { + resource: "scim_resource".into(), + identifier: x, + }, + ScimResourceProviderError::Conflict(x) => Self::Conflict(x), + ScimResourceProviderError::RaftNotAvailable => Self::InternalError( + "raft storage is not available in the scim_resource provider".to_string(), + ), + ScimResourceProviderError::RaftStoreError { source } => { + Self::InternalError(format!("raft storage error: {source}")) + } + ScimResourceProviderError::Driver { source } => { + Self::InternalError(format!("backend driver error: {source}")) + } + ScimResourceProviderError::UnsupportedDriver(x) => { + Self::InternalError(format!("unsupported driver `{x}`")) + } + ScimResourceProviderError::StructBuilder(e) => { + Self::InternalError(format!("structure builder error: {e}")) + } + // ScimResourceProviderError is non-exhaustive; catch any future variants + _ => Self::InternalError(value.to_string()), + } + } +} + impl From for KeystoneApiError { fn from(value: TokenProviderError) -> Self { match value { diff --git a/crates/api-types/src/v3/user_conv.rs b/crates/api-types/src/v3/user_conv.rs index 0bb78e0a9..c4b0da3b0 100644 --- a/crates/api-types/src/v3/user_conv.rs +++ b/crates/api-types/src/v3/user_conv.rs @@ -90,6 +90,7 @@ impl From for provider_types::UserCreate { name: user.name, options: user.options.map(Into::into), password: user.password, + user_type: provider_types::UserType::Local, } } } diff --git a/crates/api-types/src/v4.rs b/crates/api-types/src/v4.rs index ee44ba4e5..ebd501fd4 100644 --- a/crates/api-types/src/v4.rs +++ b/crates/api-types/src/v4.rs @@ -15,10 +15,13 @@ pub mod api_key; pub mod auth; pub mod mapping; +pub mod scim_realm; pub mod token_restriction; pub mod user; #[cfg(feature = "conv")] mod api_key_conv; #[cfg(feature = "conv")] +mod scim_realm_conv; +#[cfg(feature = "conv")] mod token_restriction_conv; diff --git a/crates/api-types/src/v4/scim_realm.rs b/crates/api-types/src/v4/scim_realm.rs new file mode 100644 index 000000000..005cfb6ba --- /dev/null +++ b/crates/api-types/src/v4/scim_realm.rs @@ -0,0 +1,151 @@ +// 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 +//! SCIM realm API types (ADR 0024 §2). + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "validate")] +use validator::Validate; + +use crate::Link; + +/// A registered SCIM realm. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealm { + /// Domain owning this realm. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub domain_id: String, + + /// The `provider_id` coordinate this realm authorizes for SCIM resource + /// provisioning. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub provider_id: String, + + /// The federation `IdentityProvider.id` this realm provisions users for. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub idp_id: String, + + /// Administrative display name for the realm. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 256)))] + pub display_name: String, + + /// Whether the realm currently authorizes SCIM resource provisioning. + pub enabled: bool, + + /// UTC epoch seconds. + pub created_at: i64, + + /// UTC epoch seconds. + pub updated_at: i64, +} + +/// SCIM realm creation payload. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealmCreate { + /// Domain owning this realm. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub domain_id: String, + + /// The `provider_id` coordinate this realm authorizes. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub provider_id: String, + + /// The federation `IdentityProvider.id` this realm provisions users for. + /// Must resolve to an existing identity provider. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub idp_id: String, + + /// Administrative display name for the realm. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 256)))] + pub display_name: String, +} + +/// SCIM realm creation request wrapper. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealmCreateRequest { + /// SCIM realm creation payload. + #[cfg_attr(feature = "validate", validate(nested))] + pub scim_realm: ScimRealmCreate, +} + +/// SCIM realm update payload. `None` fields are left unchanged. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealmUpdate { + /// New linked `IdentityProvider.id`. Must resolve to an existing + /// identity provider. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub idp_id: Option, + + /// New display name. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 256)))] + pub display_name: Option, + + /// Enable/disable toggle (ADR 0024 §2.B, the Realm Activation Gate). + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// SCIM realm update request wrapper. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealmUpdateRequest { + /// SCIM realm update payload. + #[cfg_attr(feature = "validate", validate(nested))] + pub scim_realm: ScimRealmUpdate, +} + +/// SCIM realm response. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealmResponse { + /// SCIM realm object. + #[cfg_attr(feature = "validate", validate(nested))] + pub scim_realm: ScimRealm, +} + +/// SCIM realm list response. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ScimRealmList { + /// Pagination links. + #[serde(skip_serializing_if = "Option::is_none")] + pub links: Option>, + /// Collection of SCIM realms. + pub scim_realms: Vec, +} + +/// SCIM realm list query parameters. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct ScimRealmListParameters { + /// Domain to list realms for. + #[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))] + pub domain_id: String, + + /// Filter by enabled/disabled state. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} diff --git a/crates/api-types/src/v4/scim_realm_conv.rs b/crates/api-types/src/v4/scim_realm_conv.rs new file mode 100644 index 000000000..ce00336d3 --- /dev/null +++ b/crates/api-types/src/v4/scim_realm_conv.rs @@ -0,0 +1,68 @@ +// 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 +//! SCIM realm conversion implementations. + +use openstack_keystone_core_types::scim as core; + +use crate::v4::scim_realm as api; + +impl From for core::ScimRealmResourceCreate { + fn from(value: api::ScimRealmCreateRequest) -> Self { + Self { + domain_id: value.scim_realm.domain_id, + provider_id: value.scim_realm.provider_id, + idp_id: value.scim_realm.idp_id, + display_name: value.scim_realm.display_name, + } + } +} + +impl From for core::ScimRealmResourceUpdate { + fn from(value: api::ScimRealmUpdateRequest) -> Self { + value.scim_realm.into() + } +} + +impl From for core::ScimRealmResourceUpdate { + fn from(value: api::ScimRealmUpdate) -> Self { + Self { + idp_id: value.idp_id, + display_name: value.display_name, + enabled: value.enabled, + } + } +} + +impl From for api::ScimRealm { + fn from(value: core::ScimRealmResource) -> Self { + Self { + domain_id: value.domain_id, + provider_id: value.provider_id, + idp_id: value.idp_id, + display_name: value.display_name, + enabled: value.enabled, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +impl From for core::ScimRealmResourceListParameters { + fn from(value: api::ScimRealmListParameters) -> Self { + Self { + domain_id: value.domain_id, + enabled: value.enabled, + } + } +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d7b3c49af..266b63e44 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -81,6 +81,8 @@ mod policy; mod resource; mod revoke; mod role; +mod scim_realm; +mod scim_resource; mod security_compliance; mod token; mod token_restriction; @@ -112,6 +114,8 @@ pub use policy::*; pub use resource::*; pub use revoke::*; pub use role::*; +pub use scim_realm::*; +pub use scim_resource::*; pub use security_compliance::*; pub use token::*; pub use token_restriction::*; @@ -226,6 +230,14 @@ pub struct Config { #[serde(default)] pub role: RoleProvider, + /// SCIM realm provider configuration (ADR 0024). + #[serde(default)] + pub scim_realm: ScimRealmProvider, + + /// SCIM resource ownership index provider configuration (ADR 0024 §3.A). + #[serde(default)] + pub scim_resource: ScimResourceProvider, + /// Security compliance configuration. #[serde(default)] #[validate(nested)] diff --git a/crates/config/src/scim_realm.rs b/crates/config/src/scim_realm.rs new file mode 100644 index 000000000..6c32bc841 --- /dev/null +++ b/crates/config/src/scim_realm.rs @@ -0,0 +1,32 @@ +// 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 +use serde::Deserialize; + +use crate::common::default_raft_driver; + +/// SCIM realm provider (ADR 0024). +#[derive(Debug, Deserialize, Clone)] +pub struct ScimRealmProvider { + /// SCIM realm provider driver. + #[serde(default = "default_raft_driver")] + pub driver: String, +} + +impl Default for ScimRealmProvider { + fn default() -> Self { + Self { + driver: default_raft_driver(), + } + } +} diff --git a/crates/config/src/scim_resource.rs b/crates/config/src/scim_resource.rs new file mode 100644 index 000000000..b2e2aafac --- /dev/null +++ b/crates/config/src/scim_resource.rs @@ -0,0 +1,32 @@ +// 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 +use serde::Deserialize; + +use crate::common::default_raft_driver; + +/// SCIM resource ownership index provider (ADR 0024 §3.A). +#[derive(Debug, Deserialize, Clone)] +pub struct ScimResourceProvider { + /// SCIM resource ownership index provider driver. + #[serde(default = "default_raft_driver")] + pub driver: String, +} + +impl Default for ScimResourceProvider { + fn default() -> Self { + Self { + driver: default_raft_driver(), + } + } +} diff --git a/crates/core-types/src/error.rs b/crates/core-types/src/error.rs index c3549c537..db2982722 100644 --- a/crates/core-types/src/error.rs +++ b/crates/core-types/src/error.rs @@ -30,6 +30,7 @@ use crate::mapping::MappingProviderError; use crate::resource::ResourceProviderError; use crate::revoke::RevokeProviderError; use crate::role::RoleProviderError; +use crate::scim::{ScimRealmProviderError, ScimResourceProviderError}; use crate::token::TokenProviderError; use crate::trust::TrustProviderError; @@ -199,6 +200,22 @@ pub enum KeystoneError { source: RoleProviderError, }, + /// SCIM realm provider. + #[error(transparent)] + ScimRealmProvider { + /// The source of the error. + #[from] + source: ScimRealmProviderError, + }, + + /// SCIM resource ownership index provider. + #[error(transparent)] + ScimResourceProvider { + /// The source of the error. + #[from] + source: ScimResourceProviderError, + }, + /// Token provider. #[error(transparent)] TokenProvider { diff --git a/crates/core-types/src/events.rs b/crates/core-types/src/events.rs index 7ea521189..cda7d410e 100644 --- a/crates/core-types/src/events.rs +++ b/crates/core-types/src/events.rs @@ -158,6 +158,11 @@ pub enum EventPayload { id: String, }, + // SCIM v2 provisioning (ADR 0024) + ScimRealm { + provider_id: String, + }, + // Trusts Trust { id: String, diff --git a/crates/core-types/src/identity/user.rs b/crates/core-types/src/identity/user.rs index 2e0d6b888..95f9fadb1 100644 --- a/crates/core-types/src/identity/user.rs +++ b/crates/core-types/src/identity/user.rs @@ -123,6 +123,14 @@ pub struct UserCreate { #[builder(default)] #[validate(length(max = 72))] pub password: Option, + + /// The kind of local-authentication row to create for the user: + /// `Local` creates a `local_user` row (password allowed), `NonLocal` + /// creates a `nonlocal_user` row (no password allowed, for externally + /// managed identities such as SCIM-provisioned users). Ignored when + /// `federated` is set. + #[builder(default = "UserType::Local")] + pub user_type: UserType, } #[derive(Builder, Clone, Debug, Default, PartialEq, Validate)] diff --git a/crates/core-types/src/lib.rs b/crates/core-types/src/lib.rs index 26b190bd2..f398bf48b 100644 --- a/crates/core-types/src/lib.rs +++ b/crates/core-types/src/lib.rs @@ -33,6 +33,7 @@ pub mod mapping; pub mod resource; pub mod revoke; pub mod role; +pub mod scim; pub mod scope; pub mod token; pub mod trust; diff --git a/crates/core-types/src/mapping/error.rs b/crates/core-types/src/mapping/error.rs index e459d7d6c..c093408cf 100644 --- a/crates/core-types/src/mapping/error.rs +++ b/crates/core-types/src/mapping/error.rs @@ -159,6 +159,25 @@ pub enum MappingProviderError { "rule '{0}' grants a non-domain scope, which is forbidden for API Key (ApiClient) mapping rulesets (only domain scope is accepted)" )] ApiClientNonDomainScopeForbidden(String), + /// A ruleset whose `provider_id` has an active SCIM realm (ADR 0024) + /// granted `Authorization::Project`, which is prohibited at write-time: + /// a SCIM realm's `ScimRealmAuth` extractor requires Domain-only scope + /// (ADR 0024 §2.C), so a Project-scoped rule sharing the same + /// `provider_id` would make realm authorization flap unpredictably + /// between pass/403 depending on which rule matched. + #[error( + "rule '{0}' grants project scope, which is forbidden for a mapping ruleset bound to an active SCIM realm (ADR 0024 \u{a7}2.C)" + )] + ScimRealmProjectScopeForbidden(String), + + /// A rule's `Authorization` granted a `RoleRef` whose `id` does not + /// correspond to any existing `Role`. `RoleRef.id` is mandatory (unlike + /// `name`, which is optional), so existence is checked against the + /// `Role` store by `id`. Without this check, a typo'd or invented role + /// reference would silently produce an authorization that can never + /// resolve to a real role -- this is rejected at write time instead. + #[error("rule references role '{0}' which does not exist")] + RoleNotFound(String), } impl MappingProviderError { diff --git a/crates/core-types/src/scim.rs b/crates/core-types/src/scim.rs new file mode 100644 index 000000000..01d28482d --- /dev/null +++ b/crates/core-types/src/scim.rs @@ -0,0 +1,22 @@ +// 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 +//! # SCIM v2 resource provisioning (ADR 0024) + +mod error; +mod index; +mod resource; + +pub use error::*; +pub use index::*; +pub use resource::*; diff --git a/crates/core-types/src/scim/error.rs b/crates/core-types/src/scim/error.rs new file mode 100644 index 000000000..fd34a101a --- /dev/null +++ b/crates/core-types/src/scim/error.rs @@ -0,0 +1,153 @@ +// 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 +//! # SCIM realm provider error + +use thiserror::Error; + +use crate::error::BuilderError; + +/// SCIM realm (`ScimRealmResource`) provider error. +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum ScimRealmProviderError { + /// Realm not found. + #[error("SCIM realm for provider `{0}` not found")] + NotFound(String), + + /// Conflict — a realm already exists for this `(domain_id, provider_id)`. + #[error("conflict: {0}")] + Conflict(String), + + /// Driver error. + #[error("backend driver error: {source}")] + Driver { + #[source] + source: Box, + }, + + /// Raft storage is not available. + #[error("raft storage is not available in the scim_realm provider")] + RaftNotAvailable, + + /// Raft storage error. + #[error("raft storage error in the scim_realm provider: {source}")] + RaftStoreError { + #[source] + source: Box, + }, + + /// Structures builder error. + #[error(transparent)] + StructBuilder(#[from] Box), + + /// Unsupported driver. + #[error("unsupported driver `{0}` for the scim_realm provider")] + UnsupportedDriver(String), +} + +impl ScimRealmProviderError { + /// Wrap a raft storage error. + pub fn raft(source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::RaftStoreError { + source: Box::new(source), + } + } + + /// Wrap a generic driver error. + pub fn driver(source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Driver { + source: Box::new(source), + } + } +} + +impl From for ScimRealmProviderError { + fn from(value: BuilderError) -> Self { + Self::StructBuilder(Box::new(value)) + } +} + +/// SCIM resource ownership index (`ScimResourceIndex`) provider error. +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum ScimResourceProviderError { + /// No ownership anchor found for the given coordinate. + #[error("SCIM resource index for `{0}` not found")] + NotFound(String), + + /// Conflict — e.g. the `externalId` is already claimed within this + /// realm (ADR 0024 §3.C/§3.D). + #[error("conflict: {0}")] + Conflict(String), + + /// Driver error. + #[error("backend driver error: {source}")] + Driver { + #[source] + source: Box, + }, + + /// Raft storage is not available. + #[error("raft storage is not available in the scim_resource provider")] + RaftNotAvailable, + + /// Raft storage error. + #[error("raft storage error in the scim_resource provider: {source}")] + RaftStoreError { + #[source] + source: Box, + }, + + /// Structures builder error. + #[error(transparent)] + StructBuilder(#[from] Box), + + /// Unsupported driver. + #[error("unsupported driver `{0}` for the scim_resource provider")] + UnsupportedDriver(String), +} + +impl ScimResourceProviderError { + /// Wrap a raft storage error. + pub fn raft(source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::RaftStoreError { + source: Box::new(source), + } + } + + /// Wrap a generic driver error. + pub fn driver(source: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Driver { + source: Box::new(source), + } + } +} + +impl From for ScimResourceProviderError { + fn from(value: BuilderError) -> Self { + Self::StructBuilder(Box::new(value)) + } +} diff --git a/crates/core-types/src/scim/index.rs b/crates/core-types/src/scim/index.rs new file mode 100644 index 000000000..b04c06c85 --- /dev/null +++ b/crates/core-types/src/scim/index.rs @@ -0,0 +1,144 @@ +// 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 +//! # SCIM resource ownership index +//! +//! See ADR 0024 (SCIM v2 Resource Provisioning) §3.A for the full design. + +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use crate::error::BuilderError; + +/// The kind of core Identity resource a [`ScimResourceIndex`] anchors. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ScimResourceType { + /// A Keystone `User`. + User, + /// A Keystone `Group`. + Group, +} + +impl ScimResourceType { + /// The lowercase key-segment representation used in storage keys. + pub fn as_key_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::Group => "group", + } + } +} + +impl std::fmt::Display for ScimResourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_key_str()) + } +} + +/// Ownership anchor for a single SCIM-provisioned `User`/`Group`. +/// +/// The sole authority for the Ownership Fencing Algorithm (ADR 0024 §3.C): +/// a resource is visible to a realm if and only if this index exists for +/// the caller's own `(domain_id, provider_id)` coordinate. Indexed in +/// storage at `data:scim_resource:v1:::: +/// `. +#[derive(Builder, Clone, Debug, Deserialize, Serialize, PartialEq)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ScimResourceIndex { + /// Domain owning this resource. + pub domain_id: String, + + /// The realm (`provider_id`) that created and exclusively owns this + /// resource (ADR 0024 §3.C). + pub provider_id: String, + + /// Whether this anchors a `User` or a `Group`. + pub resource_type: ScimResourceType, + + /// The Keystone `User.id`/`Group.id` — also the SCIM `id`. + pub keystone_id: String, + + /// The SCIM `externalId`, if the IdP supplied one. Realm-scoped unique + /// (ADR 0024 §3.B/§3.C). + #[builder(default)] + pub external_id: Option, + + /// Monotonic version, bumped on every write. Source of the SCIM ETag + /// (§5.E, a later PR). + #[builder(default)] + pub version: u64, + + /// Set on soft-disable (ADR 0024 §6.A step 2). Once set, the resource is + /// treated as absent (`404`) by all SCIM reads under this realm. + #[builder(default)] + pub deprovisioned_at: Option, + + /// UTC epoch seconds. + pub created_at: i64, + + /// UTC epoch seconds. + pub updated_at: i64, +} + +impl ScimResourceIndex { + /// Apply a partial [`ScimResourceIndexUpdate`], bumping `version` and + /// `updated_at`. + pub fn with_update(self, update: ScimResourceIndexUpdate, updated_at: i64) -> Self { + Self { + external_id: update.external_id.unwrap_or(self.external_id), + deprovisioned_at: update.deprovisioned_at.unwrap_or(self.deprovisioned_at), + version: self.version + 1, + updated_at, + ..self + } + } +} + +/// Input to anchor a newly-created SCIM resource (ADR 0024 §3.A). +#[derive(Builder, Clone, Debug, Deserialize, Serialize, PartialEq)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ScimResourceIndexCreate { + /// Domain owning this resource. + pub domain_id: String, + + /// The realm (`provider_id`) creating this resource. + pub provider_id: String, + + /// Whether this anchors a `User` or a `Group`. + pub resource_type: ScimResourceType, + + /// The Keystone `User.id`/`Group.id`. + pub keystone_id: String, + + /// The SCIM `externalId`, if supplied. + #[builder(default)] + pub external_id: Option, +} + +/// Partial update for a [`ScimResourceIndex`]. `None` fields are left +/// unchanged; `Some(None)` explicitly clears the field. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(build_fn(error = "BuilderError"))] +pub struct ScimResourceIndexUpdate { + /// `None` = unchanged. `Some(None)` clears `externalId`. + #[builder(default)] + pub external_id: Option>, + + /// `None` = unchanged. Stamped with `Some(Some(now))` on soft-disable + /// (ADR 0024 §6.A step 2). + #[builder(default)] + pub deprovisioned_at: Option>, +} diff --git a/crates/core-types/src/scim/resource.rs b/crates/core-types/src/scim/resource.rs new file mode 100644 index 000000000..176941a72 --- /dev/null +++ b/crates/core-types/src/scim/resource.rs @@ -0,0 +1,125 @@ +// 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 +//! # SCIM realm resource +//! +//! See ADR 0024 (SCIM v2 Resource Provisioning) §2 for the full design. + +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use crate::error::BuilderError; + +/// A registered SCIM realm: the explicit administrative act that enables +/// SCIM Users/Groups resource provisioning for an `(domain_id, provider_id)` +/// coordinate already used by the Unified Mapping Engine (ADR 0020) and API +/// Key ingress (ADR 0021). +/// +/// Creating an `ApiClientResource` (ADR 0021) alone does **not** enable SCIM +/// resource provisioning — a realm must be separately registered (ADR 0024 +/// §2.A). Indexed in storage at `data:scim_realm:v1::`. +#[derive(Builder, Clone, Debug, Deserialize, Serialize, PartialEq)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ScimRealmResource { + /// Domain owning this realm. + pub domain_id: String, + + /// The Unified Mapping Engine (ADR 0020) / API Key (ADR 0021) + /// `provider_id` this realm authorizes for SCIM resource provisioning. + pub provider_id: String, + + /// The federation `IdentityProvider.id` this realm provisions users for. + /// Mandatory: SCIM users are always created as `nonlocal_user` shadow + /// identities keyed by a deterministic id derived from their `externalId` + /// (see `openstack_keystone_core::identity::generate_public_id`), so + /// there is no meaningful SCIM realm that isn't tied to a real IdP. + pub idp_id: String, + + /// Administrative display name for the realm. + pub display_name: String, + + /// Whether the realm currently authorizes SCIM resource provisioning + /// (ADR 0024 §2.B, the Realm Activation Gate). + pub enabled: bool, + + /// UTC epoch seconds. + pub created_at: i64, + + /// UTC epoch seconds. + pub updated_at: i64, +} + +impl ScimRealmResource { + /// Apply a partial [`ScimRealmResourceUpdate`], returning the new + /// version to persist. + pub fn with_update(self, update: ScimRealmResourceUpdate, updated_at: i64) -> Self { + Self { + idp_id: update.idp_id.unwrap_or(self.idp_id), + display_name: update.display_name.unwrap_or(self.display_name), + enabled: update.enabled.unwrap_or(self.enabled), + updated_at, + ..self + } + } +} + +/// Input to register a new [`ScimRealmResource`] (ADR 0024 §2.A). +#[derive(Builder, Clone, Debug, Deserialize, Serialize, PartialEq)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ScimRealmResourceCreate { + /// Domain owning this realm. + pub domain_id: String, + + /// The `provider_id` coordinate this realm authorizes. + pub provider_id: String, + + /// The federation `IdentityProvider.id` this realm provisions users for. + /// Must resolve to an existing `IdentityProvider` (validated by the + /// caller) — realm creation is rejected otherwise. + pub idp_id: String, + + /// Administrative display name for the realm. + pub display_name: String, +} + +/// Partial update for a [`ScimRealmResource`] (`PATCH +/// /v4/scim-realms/{domain_id}/{provider_id}`). `None` fields are left +/// unchanged. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(build_fn(error = "BuilderError"))] +pub struct ScimRealmResourceUpdate { + /// `None` = unchanged. If set, must resolve to an existing + /// `IdentityProvider` (validated by the caller) — rejected otherwise. + pub idp_id: Option, + + /// `None` = unchanged. + pub display_name: Option, + + /// `None` = unchanged. Used for the disable/enable toggle (ADR 0024 §2.B). + pub enabled: Option, +} + +/// Filter parameters for `GET /v4/scim-realms`. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ScimRealmResourceListParameters { + /// Domain to list realms for. + pub domain_id: String, + + /// Restrict to enabled/disabled realms. + #[builder(default)] + pub enabled: Option, +} diff --git a/crates/core/src/api/api_key_auth.rs b/crates/core/src/api/api_key_auth.rs index 663c0b508..c27074773 100644 --- a/crates/core/src/api/api_key_auth.rs +++ b/crates/core/src/api/api_key_auth.rs @@ -62,105 +62,215 @@ where #[tracing::instrument(skip(state), err)] async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let state = ServiceState::from_ref(state); + let (_domain_id, resource) = resolve_verified_api_client(parts, &state).await?; - // The SCIM route is domain-scoped: `/SCIM/v2/{domain_id}/...`. The - // key lookup keyspace is partitioned by domain_id (ADR 0021 §2.A), - // so it must be known before any storage access. - let Path(domain_id) = Path::::from_request_parts(parts, &state) - .await - .map_err(|_| KeystoneApiError::from(AuthenticationError::Unauthorized))?; + // Step 4: ephemeral context hydration (anti-bleed scoping). + hydrate_ephemeral_context(&state, &resource).await + } +} - let peer_ip = parts - .extensions - .get::>() - .map(|ConnectInfo(addr)| addr.ip()); - - let presented_token = parts - .headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) - .ok_or(KeystoneApiError::UnauthorizedNoContext)?; - - let cfg = state.config_manager.config.read().await.api_key.clone(); - - // Step 1: format check + hash-based rate limiting (ADR 0021 §3 Step 1). - let parsed = match token::parse(presented_token) { - Ok(parsed) => parsed, - Err(_) => { - // No valid entropy: key the limiter on source IP instead, so - // brute-force garbage traffic doesn't bypass rate limiting by - // sending malformed tokens. - if let Some(ip) = peer_ip - && state - .api_key_rate_limiter - .check_key(&ip.to_string()) - .is_err() - { - return Err(KeystoneApiError::TooManyRequests); - } - return Err(AuthenticationError::Unauthorized.into()); - } - }; +/// Realm-aware ephemeral security context, additive to [`ApiKeyAuth`] (ADR +/// 0024 §2.C amendment to ADR 0021 §3 Step 4). +/// +/// Used exclusively by the `/SCIM/v2` resource handlers (Users/Groups) +/// introduced by ADR 0024 — never by the diagnostic `whoami` route, which +/// keeps using the plain [`ApiKeyAuth`] extractor and accepts any scope. +/// Enforces two additional invariants beyond [`ApiKeyAuth`]: +/// +/// 1. **Domain-only scope** (ADR 0024 §2.C): the resolved authorization must be +/// `ScopeInfo::Domain` matching the path's `{domain_id}` — SCIM resource +/// provisioning is a domain-level operation, never project-scoped. +/// 2. **Realm Activation Gate** (ADR 0024 §2.B): `data:scim_realm:v1: +/// :` must exist and be `enabled`, checked before +/// any User/Group storage access. +#[derive(Debug, Clone)] +pub struct ScimRealmAuth { + /// The hydrated ephemeral security context (same as [`ApiKeyAuth`]). + pub ctx: ValidatedSecurityContext, + /// The realm coordinate the request authenticated under. + pub realm: ScimRealmContext, +} - if state - .api_key_rate_limiter - .check_key(&parsed.lookup_hash) - .is_err() - { - return Err(KeystoneApiError::TooManyRequests); - } +impl Deref for ScimRealmAuth { + type Target = ValidatedSecurityContext; - // Step 2: database lookup & IP allowlisting. - use secrecy::ExposeSecret; - let entropy = parsed.entropy.expose_secret(); + fn deref(&self) -> &Self::Target { + &self.ctx + } +} - let resource = state - .provider - .get_api_key_provider() - .get_by_lookup_hash(&state, &domain_id, &parsed.lookup_hash) - .await - .map_err(|e| { - warn!(error = %e, "api_key lookup failed"); - AuthenticationError::Unauthorized - })?; +/// The `(domain_id, provider_id)` coordinate a [`ScimRealmAuth`] resolved +/// under (ADR 0024 §2.C). +#[derive(Debug, Clone)] +pub struct ScimRealmContext { + pub domain_id: String, + pub provider_id: String, +} - let Some(resource) = resource else { - // Dummy hash: burn the same Argon2id cost as a real verification - // to prevent timing-based enumeration of valid lookup hashes - // (ADR 0021 Invariant 7). - let _ = crypto::generate_dummy_hash(&cfg).await; - return Err(AuthenticationError::Unauthorized.into()); - }; +impl FromRequestParts for ScimRealmAuth +where + ServiceState: FromRef, + S: Send + Sync, +{ + type Rejection = KeystoneApiError; - let now = chrono::Utc::now().timestamp(); - if !resource.is_active(now) { - let _ = crypto::generate_dummy_hash(&cfg).await; - return Err(AuthenticationError::Unauthorized.into()); - } + #[tracing::instrument(skip(state), err)] + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let state = ServiceState::from_ref(state); + let (domain_id, resource) = resolve_verified_api_client(parts, &state).await?; + let provider_id = resource.provider_id.clone(); - let effective_ip = resolve_client_ip(&parts.headers, peer_ip, &cfg.trusted_proxies); - if !ip_allowed(effective_ip, &resource.allowed_ips) { + let ApiKeyAuth(ctx) = hydrate_ephemeral_context(&state, &resource).await?; + + // Invariant 1: Domain-only scope (ADR 0024 §2.C). + if !is_domain_scoped(&ctx, &domain_id) { return Err(AuthenticationError::Unauthorized.into()); } - // Step 3: cryptographic verification & lazy re-hash. - let verified = crypto::verify_secret(entropy, &resource.secret_hash) + // Invariant 2: Realm Activation Gate (ADR 0024 §2.B). Checked before + // any User/Group storage access. + let exec = ExecutionContext::internal(&state); + let realm = state + .provider + .get_scim_realm_provider() + .get_realm(&exec, &domain_id, &provider_id) .await .map_err(|e| { - warn!(error = %e, "api_key argon2 verification errored"); + warn!(error = %e, "scim_realm lookup failed"); AuthenticationError::Unauthorized })?; - if !verified { + match realm { + Some(realm) if realm.enabled => {} + _ => return Err(AuthenticationError::Unauthorized.into()), + } + + Ok(ScimRealmAuth { + ctx, + realm: ScimRealmContext { + domain_id, + provider_id, + }, + }) + } +} + +/// Steps 1–3 of the API Key ingress pipeline (ADR 0021 §3): format check + +/// rate limiting, database lookup + IP allowlisting, cryptographic +/// verification. Shared by [`ApiKeyAuth`] and [`ScimRealmAuth`] so both +/// extractors authenticate identically before diverging on scope/realm +/// policy (ADR 0024 §2.C). +async fn resolve_verified_api_client( + parts: &mut Parts, + state: &ServiceState, +) -> Result<(String, ApiClientResource), KeystoneApiError> { + // The SCIM route is domain-scoped: `/SCIM/v2/{domain_id}/...`. The + // key lookup keyspace is partitioned by domain_id (ADR 0021 §2.A), + // so it must be known before any storage access. + // + // Extracted as a map rather than `Path`: axum's `Path` for a + // scalar `T` requires the *entire* matched route (including nested + // routers) to carry exactly one dynamic segment. ADR 0024's resource + // routes (e.g. `/{domain_id}/Users/{id}`) have two, which would + // otherwise make this extraction fail with `WrongNumberOfParameters` + // for every show/update/delete request. + let Path(params) = + Path::>::from_request_parts(parts, state) + .await + .map_err(|_| KeystoneApiError::from(AuthenticationError::Unauthorized))?; + let domain_id = params + .get("domain_id") + .ok_or(KeystoneApiError::from(AuthenticationError::Unauthorized))? + .clone(); + + let peer_ip = parts + .extensions + .get::>() + .map(|ConnectInfo(addr)| addr.ip()); + + let presented_token = parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .ok_or(KeystoneApiError::UnauthorizedNoContext)?; + + let cfg = state.config_manager.config.read().await.api_key.clone(); + + // Step 1: format check + hash-based rate limiting (ADR 0021 §3 Step 1). + let parsed = match token::parse(presented_token) { + Ok(parsed) => parsed, + Err(_) => { + // No valid entropy: key the limiter on source IP instead, so + // brute-force garbage traffic doesn't bypass rate limiting by + // sending malformed tokens. + if let Some(ip) = peer_ip + && state + .api_key_rate_limiter + .check_key(&ip.to_string()) + .is_err() + { + return Err(KeystoneApiError::TooManyRequests); + } return Err(AuthenticationError::Unauthorized.into()); } + }; - spawn_lazy_rehash(&state, &resource, entropy.to_string(), cfg.clone()); - spawn_last_used_update(&state, &resource, now); + if state + .api_key_rate_limiter + .check_key(&parsed.lookup_hash) + .is_err() + { + return Err(KeystoneApiError::TooManyRequests); + } - // Step 4: ephemeral context hydration (anti-bleed scoping). - hydrate_ephemeral_context(&state, &resource).await + // Step 2: database lookup & IP allowlisting. + use secrecy::ExposeSecret; + let entropy = parsed.entropy.expose_secret(); + + let resource = state + .provider + .get_api_key_provider() + .get_by_lookup_hash(state, &domain_id, &parsed.lookup_hash) + .await + .map_err(|e| { + warn!(error = %e, "api_key lookup failed"); + AuthenticationError::Unauthorized + })?; + + let Some(resource) = resource else { + // Dummy hash: burn the same Argon2id cost as a real verification + // to prevent timing-based enumeration of valid lookup hashes + // (ADR 0021 Invariant 7). + let _ = crypto::generate_dummy_hash(&cfg).await; + return Err(AuthenticationError::Unauthorized.into()); + }; + + let now = chrono::Utc::now().timestamp(); + if !resource.is_active(now) { + let _ = crypto::generate_dummy_hash(&cfg).await; + return Err(AuthenticationError::Unauthorized.into()); + } + + let effective_ip = resolve_client_ip(&parts.headers, peer_ip, &cfg.trusted_proxies); + if !ip_allowed(effective_ip, &resource.allowed_ips) { + return Err(AuthenticationError::Unauthorized.into()); } + + // Step 3: cryptographic verification & lazy re-hash. + let verified = crypto::verify_secret(entropy, &resource.secret_hash) + .await + .map_err(|e| { + warn!(error = %e, "api_key argon2 verification errored"); + AuthenticationError::Unauthorized + })?; + if !verified { + return Err(AuthenticationError::Unauthorized.into()); + } + + spawn_lazy_rehash(state, &resource, entropy.to_string(), cfg.clone()); + spawn_last_used_update(state, &resource, now); + + Ok((domain_id, resource)) } /// Fire-and-forget re-hash of the stored secret if its PHC parameters fall @@ -278,6 +388,22 @@ fn ip_allowed(client_ip: Option, allowed_ips: &Option>) -> b .any(|net| net.contains(&ip)) } +/// Whether a hydrated [`ValidatedSecurityContext`] resolved to exactly +/// `ScopeInfo::Domain` matching `domain_id` (ADR 0024 §2.C Domain-only scope +/// restriction). A missing authorization, or any other scope variant +/// (Project/System/Unscoped/TrustProject), is never domain-scoped. +fn is_domain_scoped(ctx: &ValidatedSecurityContext, domain_id: &str) -> bool { + ctx.inner() + .authorization() + .map(|authz| { + matches!( + &authz.scope, + openstack_keystone_core_types::auth::ScopeInfo::Domain(d) if d.id == domain_id + ) + }) + .unwrap_or(false) +} + /// Evaluate the API Key's bound mapping ruleset and hydrate an /// [`ApiKeyAuth`] under exactly one scope (ADR 0021 §3 Step 4). async fn hydrate_ephemeral_context( @@ -396,6 +522,90 @@ mod tests { use super::*; use axum::http::HeaderMap; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::resource::Domain; + + fn vsc_with_scope(scope: ScopeInfo) -> ValidatedSecurityContext { + let user = UserIdentityInfoBuilder::default() + .user_id("uid".to_string()) + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .scope(scope) + .roles(Vec::new()) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ValidatedSecurityContext::test_new(sc) + } + + // --------------------------------------------------------------------- + // is_domain_scoped (ADR 0024 §2.C Domain-only scope restriction) + // --------------------------------------------------------------------- + + #[test] + fn domain_scope_matching_domain_id_is_domain_scoped() { + let vsc = vsc_with_scope(ScopeInfo::Domain(Domain { + id: "domain-1".to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })); + assert!(is_domain_scoped(&vsc, "domain-1")); + } + + #[test] + fn domain_scope_mismatched_domain_id_is_not_domain_scoped() { + let vsc = vsc_with_scope(ScopeInfo::Domain(Domain { + id: "domain-1".to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })); + assert!(!is_domain_scoped(&vsc, "domain-2")); + } + + #[test] + fn project_scope_is_never_domain_scoped() { + let vsc = vsc_with_scope(ScopeInfo::Project { + project: openstack_keystone_core_types::resource::Project { + id: "project-1".to_string(), + domain_id: "domain-1".to_string(), + name: String::new(), + description: None, + enabled: true, + is_domain: false, + parent_id: None, + extra: Default::default(), + }, + project_domain: Domain { + id: "domain-1".to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + }, + }); + assert!(!is_domain_scoped(&vsc, "domain-1")); + } + + #[test] + fn unscoped_is_never_domain_scoped() { + let vsc = vsc_with_scope(ScopeInfo::Unscoped); + assert!(!is_domain_scoped(&vsc, "domain-1")); + } + fn headers_with_xff(xff: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert( diff --git a/crates/core/src/cadf_hook.rs b/crates/core/src/cadf_hook.rs index 9349cfcb2..a2a4e0207 100644 --- a/crates/core/src/cadf_hook.rs +++ b/crates/core/src/cadf_hook.rs @@ -98,6 +98,9 @@ fn build_target_from_event(event: &Event) -> Target { } EventPayload::VirtualUser { user_id: id } => (id, "data/security/identity/virtual-user"), EventPayload::K8sAuthInstance { id } => (id, "data/security/identity/k8s-auth-instance"), + EventPayload::ScimRealm { provider_id } => { + (provider_id, "data/security/identity/scim-realm") + } }; Target { id: sanitize_audit_id(raw_id), diff --git a/crates/core/src/identity/backend.rs b/crates/core/src/identity/backend.rs index 2848e1b19..9b74c32bd 100644 --- a/crates/core/src/identity/backend.rs +++ b/crates/core/src/identity/backend.rs @@ -201,6 +201,21 @@ pub trait IdentityBackend: Send + Sync { user_id: &'a str, ) -> Result; + /// Find the `user_id` of any user in `domain_id` whose name matches + /// `name`, case-insensitively, regardless of which realm (or nothing) + /// created it (ADR 0024 §3.D domain-wide uniqueness check). + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + async fn find_user_by_name_ci<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError>; + /// Find federated user by IDP and Unique ID. /// /// # Parameters diff --git a/crates/core/src/identity/mod.rs b/crates/core/src/identity/mod.rs index 2c7621a30..e897e1cb0 100644 --- a/crates/core/src/identity/mod.rs +++ b/crates/core/src/identity/mod.rs @@ -42,11 +42,13 @@ pub mod error; pub mod hook; mod provider_api; pub mod service; +pub mod shadow_id; pub use error::IdentityProviderError; pub use hook::IdentityHook; pub use provider_api::IdentityApi; pub use service::IdentityService; +pub use shadow_id::generate_public_id; #[cfg(any(test, feature = "mock"))] pub use crate::mocks::MockIdentityProvider; diff --git a/crates/core/src/identity/provider_api.rs b/crates/core/src/identity/provider_api.rs index 37ae4f859..2db07dd52 100644 --- a/crates/core/src/identity/provider_api.rs +++ b/crates/core/src/identity/provider_api.rs @@ -205,6 +205,21 @@ pub trait IdentityApi: Send + Sync { user_id: &'a str, ) -> Result; + /// Find the `user_id` of any user in `domain_id` whose name matches + /// `name`, case-insensitively, regardless of which realm (or nothing) + /// created it (ADR 0024 §3.D domain-wide uniqueness check). + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + async fn find_user_by_name_ci<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError>; + /// Find a federated user by IDP and unique ID. /// /// # Parameters diff --git a/crates/core/src/identity/service.rs b/crates/core/src/identity/service.rs index 0cb622aa1..20cbb0e9c 100644 --- a/crates/core/src/identity/service.rs +++ b/crates/core/src/identity/service.rs @@ -816,6 +816,17 @@ impl IdentityApi for IdentityService { } } + async fn find_user_by_name_ci<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError> { + self.backend_driver + .find_user_by_name_ci(ctx.state(), domain_id, name) + .await + } + /// Find federated user by `idp_id` and `unique_id`. /// /// # Parameters diff --git a/crates/core/src/identity/shadow_id.rs b/crates/core/src/identity/shadow_id.rs new file mode 100644 index 000000000..b3bf88bde --- /dev/null +++ b/crates/core/src/identity/shadow_id.rs @@ -0,0 +1,83 @@ +// 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 + +//! Deterministic user id generation, bit-compatible with python-keystone's +//! `keystone.identity.id_generators.sha256.Generator.generate_public_ID`. +//! +//! python-keystone derives shadow/nonlocal user ids as `sha256` over the +//! *values* of `{domain_id, local_id, entity_type}`, iterated in +//! alphabetically-sorted key order (`domain_id`, `entity_type`, `local_id`), +//! UTF-8 encoded, no separators, hex digest. This is a pure function: the +//! same triple always yields the same id, which is what lets SCIM-provisioned +//! users and JIT-provisioned federated users converge on one user row when +//! `local_id` (SCIM `externalId` / the IdP's `sub` claim) matches, without +//! any lookup table. +use sha2::{Digest, Sha256}; + +/// Deterministically derive a user id from `(domain_id, local_id, +/// entity_type)`, matching python-keystone's sha256 id generator exactly. +pub fn generate_public_id(domain_id: &str, local_id: &str, entity_type: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain_id.as_bytes()); + hasher.update(entity_type.as_bytes()); + hasher.update(local_id.as_bytes()); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Golden value computed directly from python-keystone's + /// `keystone/identity/id_generators/sha256.py`: + /// ```python + /// import hashlib + /// m = hashlib.sha256() + /// mapping = {'domain_id': 'd1', 'local_id': 'ext-1', 'entity_type': 'user'} + /// for key in sorted(mapping.keys()): + /// m.update(mapping[key].encode('utf-8')) + /// m.hexdigest() + /// ``` + #[test] + fn test_matches_python_keystone_golden_value() { + assert_eq!( + generate_public_id("d1", "ext-1", "user"), + "706be650a00d566f9d10d02258a814a7049afa6ccebf52f278a44f83c09daa5d" + ); + } + + #[test] + fn test_deterministic() { + assert_eq!( + generate_public_id("domain", "local", "user"), + generate_public_id("domain", "local", "user") + ); + } + + #[test] + fn test_distinguishes_inputs() { + assert_ne!( + generate_public_id("domain-a", "local", "user"), + generate_public_id("domain-b", "local", "user") + ); + assert_ne!( + generate_public_id("domain", "local-a", "user"), + generate_public_id("domain", "local-b", "user") + ); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a71e5c196..66243a22e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -101,6 +101,8 @@ pub mod provider; pub mod resource; pub mod revoke; pub mod role; +pub mod scim_realm; +pub mod scim_resource; pub mod token; pub mod trust; diff --git a/crates/core/src/mapping/service.rs b/crates/core/src/mapping/service.rs index 048a72234..da2364544 100644 --- a/crates/core/src/mapping/service.rs +++ b/crates/core/src/mapping/service.rs @@ -45,6 +45,98 @@ use crate::mapping::{ }; use crate::plugin_manager::PluginManagerApi; +/// Reject `Authorization::Project` entries in a ruleset whose `source` is +/// `IdentitySource::ApiClient` and whose `provider_id` has an active +/// `ScimRealmResource` (ADR 0024 §2.C write-time ruleset constraint). +/// +/// Mirrors `validate_api_client_no_system_scope`'s defense-in-depth +/// rationale (ADR 0021 §6.C): `ScimRealmAuth` already enforces Domain-only +/// scope at authentication time, but nothing stops an operator from adding a +/// `Project`-scoped rule to the same ruleset for an unrelated claim match, +/// which would make that enforcement flap unpredictably per-request. A +/// realm lookup is required (async), so this check lives here rather than +/// in the synchronous `validation` module. +async fn validate_scim_realm_no_project_scope<'a>( + ctx: &ExecutionContext<'a>, + domain_id: Option<&str>, + source: &IdentitySource, + rules: &[MappingRule], +) -> Result<(), MappingProviderError> { + let IdentitySource::ApiClient { provider_id } = source else { + return Ok(()); + }; + let Some(domain_id) = domain_id else { + return Ok(()); + }; + let realm = ctx + .state() + .provider + .get_scim_realm_provider() + .get_realm(ctx, domain_id, provider_id) + .await + .map_err(MappingProviderError::driver)?; + let Some(realm) = realm else { + return Ok(()); + }; + if !realm.enabled { + return Ok(()); + } + for rule in rules { + if rule + .authorizations + .iter() + .any(|auth| matches!(auth, Authorization::Project { .. })) + { + return Err(MappingProviderError::ScimRealmProjectScopeForbidden( + rule.name.clone(), + )); + } + } + Ok(()) +} + +/// Reject any `RoleRef` in a rule's +/// `Authorization::{Project,Domain,System}.roles` whose `id` does not +/// correspond to an existing `Role`. +/// +/// `RoleRef.id` is mandatory (unlike `name`, which is optional), so this +/// check resolves against the `Role` store by `id`. Without it, a typo'd or +/// invented role reference (e.g. the `SystemAdmin`/`DomainManager` +/// naming-drift bug) silently produces an authorization that can never +/// satisfy any policy check, instead of being rejected at write time. +async fn validate_roles_exist<'a>( + ctx: &ExecutionContext<'a>, + rules: &[MappingRule], +) -> Result<(), MappingProviderError> { + let mut checked: HashSet<&str> = HashSet::new(); + for rule in rules { + for auth in &rule.authorizations { + let roles = match auth { + Authorization::Project { roles, .. } => roles, + Authorization::Domain { roles, .. } => roles, + Authorization::System { roles, .. } => roles, + }; + for role_ref in roles { + let key = role_ref.id.as_str(); + if !checked.insert(key) { + continue; + } + let existing = ctx + .state() + .provider + .get_role_provider() + .get_role(ctx, key) + .await + .map_err(MappingProviderError::driver)?; + if existing.is_none() { + return Err(MappingProviderError::RoleNotFound(key.to_string())); + } + } + } + } + Ok(()) +} + /// Derive [`AuthzInfo`] from authorization list produced by a matched ruleset /// rule. /// @@ -396,6 +488,21 @@ impl MappingService { let identity_provider = state.provider.get_identity_provider(); + // A user already provisioned via SCIM (or any other python-keystone- + // compatible shadow mechanism) with this same `(domain_id, + // unique_workload_id)` pair has the exact same deterministic id — + // reuse it directly instead of creating a duplicate `federated_user` + // row for the same real person (ADR 0024 dedup fix). + let candidate_id = + crate::identity::generate_public_id(domain_id, unique_workload_id, "user"); + if let Some(existing) = identity_provider + .get_user(&ctx, &candidate_id) + .await + .map_err(MappingProviderError::driver)? + { + return Ok(existing); + } + if let Some(existing) = identity_provider .find_federated_user(&ctx, idp_id, unique_workload_id) .await @@ -415,6 +522,14 @@ impl MappingService { let mut user_builder = UserCreateBuilder::default(); user_builder + // Same deterministic id a SCIM (or other python-keystone- + // compatible shadow) provisioning of this same `(domain_id, + // unique_workload_id)` pair would compute. Setting it explicitly + // here (rather than letting the backend assign a random id) + // closes the reverse-ordering half of the ADR 0024 dedup fix: + // federation-first-then-SCIM-second now converges on one row + // too, not just SCIM-first-then-federation-second. + .id(candidate_id.clone()) .domain_id(domain_id.to_string()) .enabled(true) .name(user_name.to_string()) @@ -599,6 +714,14 @@ impl MappingApi for MappingService { mut ruleset: MappingRuleSetCreate, ) -> Result { validation::validate_ruleset_create(&ruleset)?; + validate_scim_realm_no_project_scope( + ctx, + ruleset.domain_id.as_deref(), + &ruleset.source, + &ruleset.rules, + ) + .await?; + validate_roles_exist(ctx, &ruleset.rules).await?; let mapping_id = ruleset .mapping_id .take() @@ -960,6 +1083,15 @@ impl MappingApi for MappingService { }; validation::validate_ruleset_create(&create_payload)?; + validate_scim_realm_no_project_scope( + ctx, + existing.domain_id.as_deref(), + &existing.source, + &create_payload.rules, + ) + .await?; + validate_roles_exist(ctx, &create_payload.rules).await?; + // 5. Compute new version let new_version = version::compute_ruleset_version(&create_payload); @@ -1038,6 +1170,16 @@ impl MappingApi for MappingService { validation::validate_ruleset_update(&existing, &data)?; let merged_rules = data.rules.clone().unwrap_or(existing.rules.clone()); + + validate_scim_realm_no_project_scope( + ctx, + existing.domain_id.as_deref(), + &existing.source, + &merged_rules, + ) + .await?; + validate_roles_exist(ctx, &merged_rules).await?; + let new_version = version::compute_ruleset_version_from_parts( &existing.mapping_id, existing.domain_id.as_deref(), @@ -1281,6 +1423,341 @@ mod tests { ); } + fn project_scope_rule() -> MappingRule { + MappingRule { + name: "grant-project".to_string(), + description: None, + r#match: MatchCriteria::AllOf(vec![]), + identity: IdentityBinding { + identity_mode: None, + user_name: "test".to_string(), + user_id: None, + user_domain_id: None, + is_system: false, + }, + authorizations: vec![Authorization::Project { + project_id: "project-1".to_string(), + project_domain_id: "domain-1".to_string(), + roles: vec![], + }], + groups: vec![], + } + } + + // --------------------------------------------------------------------- + // RoleRef existence validation (`validate_roles_exist`) + // --------------------------------------------------------------------- + + fn domain_scope_rule_with_role(role_name: &str) -> MappingRule { + MappingRule { + name: "grant-domain".to_string(), + description: None, + r#match: MatchCriteria::AllOf(vec![]), + identity: IdentityBinding { + identity_mode: None, + user_name: "test".to_string(), + user_id: None, + user_domain_id: None, + is_system: false, + }, + authorizations: vec![Authorization::Domain { + domain_id: "domain-1".to_string(), + roles: vec![RoleRef { + id: role_name.to_string(), + name: Some(role_name.to_string()), + domain_id: None, + }], + }], + groups: vec![], + } + } + + #[tokio::test] + async fn test_create_ruleset_rejects_nonexistent_role() { + let mut role_mock = crate::role::MockRoleProvider::default(); + role_mock.expect_get_role().returning(|_, _| Ok(None)); + let provider = crate::provider::Provider::mocked_builder().mock_role(role_mock); + let state = get_mocked_state(None, Some(provider)).await; + + let service = MappingService::from_driver(MockMappingBackend::new()); + let ruleset_create = MappingRuleSetCreate { + mapping_id: None, + domain_id: Some("domain-1".to_string()), + source: IdentitySource::Federation { + idp_id: "test-idp".to_string(), + }, + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules: vec![domain_scope_rule_with_role("NotARealRole")], + }; + + let result = service + .create_ruleset(&ExecutionContext::internal(&state), ruleset_create) + .await; + + assert!(matches!( + result.unwrap_err(), + MappingProviderError::RoleNotFound(name) if name == "NotARealRole" + )); + } + + #[tokio::test] + async fn test_create_ruleset_accepts_existing_role() { + let mut role_mock = crate::role::MockRoleProvider::default(); + role_mock.expect_get_role().returning(|_, role_id| { + if role_id == "manager" { + Ok(Some( + openstack_keystone_core_types::role::RoleBuilder::default() + .id("manager") + .name("manager") + .build() + .unwrap(), + )) + } else { + Ok(None) + } + }); + let provider = crate::provider::Provider::mocked_builder().mock_role(role_mock); + let state = get_mocked_state(None, Some(provider)).await; + + let mut mock_backend = MockMappingBackend::new(); + let ruleset_create = MappingRuleSetCreate { + mapping_id: None, + domain_id: Some("domain-1".to_string()), + source: IdentitySource::Federation { + idp_id: "test-idp".to_string(), + }, + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules: vec![domain_scope_rule_with_role("manager")], + }; + let expected = MappingRuleSet { + mapping_id: "generated-id".to_string(), + domain_id: ruleset_create.domain_id.clone(), + source: ruleset_create.source.clone(), + domain_resolution_mode: ruleset_create.domain_resolution_mode.clone(), + enabled: ruleset_create.enabled, + rules: ruleset_create.rules.clone(), + ruleset_version: 1, + }; + let expected_clone = expected.clone(); + mock_backend + .expect_create_ruleset() + .returning(move |_, _| Ok(expected_clone.clone())); + + let service = MappingService::from_driver(mock_backend); + + let result = service + .create_ruleset(&ExecutionContext::internal(&state), ruleset_create) + .await; + + assert!(result.is_ok()); + } + + // --------------------------------------------------------------------- + // ADR 0024 §2.C: Authorization::Project forbidden on SCIM realm rulesets + // --------------------------------------------------------------------- + + #[tokio::test] + async fn test_create_ruleset_rejects_project_scope_for_active_scim_realm() { + let mut scim_realm_mock = crate::scim_realm::MockScimRealmProvider::default(); + scim_realm_mock + .expect_get_realm() + .withf(|_, domain_id, provider_id| { + domain_id == "domain-1" && provider_id == "provider-1" + }) + .returning(|_, _, _| { + Ok(Some( + openstack_keystone_core_types::scim::ScimRealmResource { + domain_id: "domain-1".to_string(), + provider_id: "provider-1".to_string(), + idp_id: "idp-1".to_string(), + display_name: "Okta".to_string(), + enabled: true, + created_at: 0, + updated_at: 0, + }, + )) + }); + let provider = crate::provider::Provider::mocked_builder().mock_scim_realm(scim_realm_mock); + let state = get_mocked_state(None, Some(provider)).await; + + let mock_backend = MockMappingBackend::new(); + let service = MappingService::from_driver(mock_backend); + + let ruleset_create = MappingRuleSetCreate { + mapping_id: None, + domain_id: Some("domain-1".to_string()), + source: IdentitySource::ApiClient { + provider_id: "provider-1".to_string(), + }, + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules: vec![project_scope_rule()], + }; + + let result = service + .create_ruleset(&ExecutionContext::internal(&state), ruleset_create) + .await; + + assert!(matches!( + result.unwrap_err(), + MappingProviderError::ScimRealmProjectScopeForbidden(name) if name == "grant-project" + )); + } + + #[tokio::test] + async fn test_create_ruleset_allows_project_scope_when_realm_disabled() { + let mut scim_realm_mock = crate::scim_realm::MockScimRealmProvider::default(); + scim_realm_mock.expect_get_realm().returning(|_, _, _| { + Ok(Some( + openstack_keystone_core_types::scim::ScimRealmResource { + domain_id: "domain-1".to_string(), + provider_id: "provider-1".to_string(), + idp_id: "idp-1".to_string(), + display_name: "Okta".to_string(), + enabled: false, + created_at: 0, + updated_at: 0, + }, + )) + }); + let provider = crate::provider::Provider::mocked_builder().mock_scim_realm(scim_realm_mock); + let state = get_mocked_state(None, Some(provider)).await; + + let mut mock_backend = MockMappingBackend::new(); + let ruleset_create = MappingRuleSetCreate { + mapping_id: None, + domain_id: Some("domain-1".to_string()), + source: IdentitySource::ApiClient { + provider_id: "provider-1".to_string(), + }, + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules: vec![project_scope_rule()], + }; + let expected = MappingRuleSet { + mapping_id: "generated-id".to_string(), + domain_id: ruleset_create.domain_id.clone(), + source: ruleset_create.source.clone(), + domain_resolution_mode: ruleset_create.domain_resolution_mode.clone(), + enabled: ruleset_create.enabled, + rules: ruleset_create.rules.clone(), + ruleset_version: 1, + }; + let expected_clone = expected.clone(); + mock_backend + .expect_create_ruleset() + .returning(move |_, _| Ok(expected_clone.clone())); + + let service = MappingService::from_driver(mock_backend); + + let result = service + .create_ruleset(&ExecutionContext::internal(&state), ruleset_create) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_create_ruleset_allows_project_scope_for_federation_source() { + // Non-ApiClient sources are never subject to the SCIM realm + // constraint — the realm provider must not even be consulted. + let mut scim_realm_mock = crate::scim_realm::MockScimRealmProvider::default(); + scim_realm_mock.expect_get_realm().never(); + let provider = crate::provider::Provider::mocked_builder().mock_scim_realm(scim_realm_mock); + let state = get_mocked_state(None, Some(provider)).await; + + let mut mock_backend = MockMappingBackend::new(); + let ruleset_create = MappingRuleSetCreate { + mapping_id: None, + domain_id: Some("domain-1".to_string()), + source: IdentitySource::Federation { + idp_id: "okta".to_string(), + }, + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules: vec![project_scope_rule()], + }; + let expected = MappingRuleSet { + mapping_id: "generated-id".to_string(), + domain_id: ruleset_create.domain_id.clone(), + source: ruleset_create.source.clone(), + domain_resolution_mode: ruleset_create.domain_resolution_mode.clone(), + enabled: ruleset_create.enabled, + rules: ruleset_create.rules.clone(), + ruleset_version: 1, + }; + let expected_clone = expected.clone(); + mock_backend + .expect_create_ruleset() + .returning(move |_, _| Ok(expected_clone.clone())); + + let service = MappingService::from_driver(mock_backend); + + let result = service + .create_ruleset(&ExecutionContext::internal(&state), ruleset_create) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_update_ruleset_rejects_project_scope_for_active_scim_realm() { + let mut scim_realm_mock = crate::scim_realm::MockScimRealmProvider::default(); + scim_realm_mock.expect_get_realm().returning(|_, _, _| { + Ok(Some( + openstack_keystone_core_types::scim::ScimRealmResource { + domain_id: "domain-1".to_string(), + provider_id: "provider-1".to_string(), + idp_id: "idp-1".to_string(), + display_name: "Okta".to_string(), + enabled: true, + created_at: 0, + updated_at: 0, + }, + )) + }); + let provider = crate::provider::Provider::mocked_builder().mock_scim_realm(scim_realm_mock); + let state = get_mocked_state(None, Some(provider)).await; + + let mapping_id = "test-id"; + let mut mock_backend = MockMappingBackend::new(); + mock_backend.expect_get_ruleset().returning(move |_, _| { + Ok(Some(MappingRuleSet { + mapping_id: mapping_id.to_string(), + domain_id: Some("domain-1".to_string()), + source: IdentitySource::ApiClient { + provider_id: "provider-1".to_string(), + }, + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules: vec![], + ruleset_version: 1, + })) + }); + mock_backend.expect_update_ruleset().never(); + + let service = MappingService::from_driver(mock_backend); + + let result = service + .update_ruleset( + &ExecutionContext::internal(&state), + mapping_id, + MappingRuleSetUpdate { + enabled: Some(true), + allowed_domains: None, + rules: Some(vec![project_scope_rule()]), + }, + ) + .await; + + assert!(matches!( + result.unwrap_err(), + MappingProviderError::ScimRealmProjectScopeForbidden(name) if name == "grant-project" + )); + } + #[tokio::test] async fn test_delete_ruleset() { let mut mock_backend = MockMappingBackend::new(); @@ -2229,6 +2706,256 @@ mod tests { } } + #[tokio::test] + async fn test_authenticate_local_reuses_scim_provisioned_user() { + // ADR 0024 dedup fix: a user already provisioned via SCIM with the + // same deterministic id as this federated login would derive must + // be reused directly, without ever touching `find_federated_user` + // or `create_user` — proving the pre-check in + // `find_or_create_federated_user` short-circuits before the + // legacy `federated_user`-table lookup/create fallback. + let mut mock_backend = MockMappingBackend::new(); + + let source = IdentitySource::Federation { + idp_id: "okta".to_string(), + }; + + let rules = vec![MappingRule { + name: "matching-rule".to_string(), + description: None, + r#match: MatchCriteria::AllOf(vec![MatchCondition::Condition( + ClaimCondition::Equals { + claim: "sub".to_string(), + value: Value::String("ext-1".to_string()), + }, + )]), + identity: IdentityBinding { + identity_mode: Some(IdentityMode::Local), + user_name: "should-not-be-used".to_string(), + user_id: None, + user_domain_id: None, + is_system: false, + }, + authorizations: vec![], + groups: vec![], + }]; + + let matching_ruleset = MappingRuleSet { + mapping_id: "test-mapping".to_string(), + domain_id: Some("domain-1".to_string()), + source: source.clone(), + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules, + ruleset_version: 1, + }; + + mock_backend + .expect_get_ruleset_by_source() + .returning(move |_, _, _| Ok(Some(matching_ruleset.clone()))); + + let scim_user_id = crate::identity::generate_public_id("domain-1", "ext-1", "user"); + let scim_user_id_clone = scim_user_id.clone(); + + let mut federation_mock = crate::federation::MockFederationProvider::default(); + federation_mock + .expect_get_identity_provider() + .returning(|_, id| { + Ok(Some( + openstack_keystone_core_types::federation::IdentityProviderBuilder::default() + .id(id) + .name("okta") + .enabled(true) + .build() + .unwrap(), + )) + }); + + let mut identity_mock = crate::identity::MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(move |_, id| { + if id == scim_user_id_clone { + Ok(Some( + openstack_keystone_core_types::identity::UserResponseBuilder::default() + .id(id.to_string()) + .domain_id("domain-1".to_string()) + .name("scim-provisioned-user".to_string()) + .enabled(true) + .build() + .unwrap(), + )) + } else { + Ok(None) + } + }); + // Must never be reached: the deterministic-id pre-check short- + // circuits before this legacy lookup/create fallback. + identity_mock.expect_find_federated_user().never(); + identity_mock.expect_create_user().never(); + identity_mock + .expect_list_groups_of_user() + .returning(|_, _| Ok(vec![])); + identity_mock + .expect_list_groups() + .returning(|_, _| Ok(vec![])); + + let provider = crate::provider::Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_federation(federation_mock); + let mut cfg = Config::default(); + cfg.mapping.cluster_salt = Some(SecretString::from("test-salt-for-hmac-derivation!")); + let state = get_mocked_state(Some(cfg), Some(provider)).await; + + let service = MappingService::from_driver(mock_backend); + + let mut claims = HashMap::new(); + claims.insert("sub".to_string(), vec!["ext-1".to_string()]); + + let result = service + .authenticate_by_mapping( + &ExecutionContext::internal(&state), + &MappingAuthRequest { + domain_id: Some("domain-1".to_string()), + source: source.clone(), + unique_workload_id: "ext-1".to_string(), + claims, + rule_name: None, + }, + ) + .await + .unwrap(); + + let AuthenticationContext::Oidc { .. } = result.context else { + panic!("Expected Oidc authentication context"); + }; + let IdentityInfo::User(user_identity) = result.principal.identity else { + panic!("Expected a User principal"); + }; + assert_eq!(user_identity.user_id, scim_user_id); + } + + #[tokio::test] + async fn test_authenticate_local_creates_new_federated_user_with_deterministic_id() { + // ADR 0024 dedup fix, reverse ordering: when federation JIT- + // provisions a brand-new user (no SCIM-provisioned row yet), the + // `create_user` call must still receive the same deterministic id + // `generate_public_id(domain_id, sub, "user")` would compute — so + // that a *later* SCIM provisioning of the same person (matching + // `externalId` == this `sub`) converges on this same row instead of + // creating a duplicate. + let mut mock_backend = MockMappingBackend::new(); + + let source = IdentitySource::Federation { + idp_id: "okta".to_string(), + }; + + let rules = vec![MappingRule { + name: "matching-rule".to_string(), + description: None, + r#match: MatchCriteria::AllOf(vec![MatchCondition::Condition( + ClaimCondition::Equals { + claim: "sub".to_string(), + value: Value::String("ext-2".to_string()), + }, + )]), + identity: IdentityBinding { + identity_mode: Some(IdentityMode::Local), + user_name: "jit-user".to_string(), + user_id: None, + user_domain_id: None, + is_system: false, + }, + authorizations: vec![], + groups: vec![], + }]; + + let matching_ruleset = MappingRuleSet { + mapping_id: "test-mapping".to_string(), + domain_id: Some("domain-1".to_string()), + source: source.clone(), + domain_resolution_mode: DomainResolutionMode::Fixed, + enabled: true, + rules, + ruleset_version: 1, + }; + + mock_backend + .expect_get_ruleset_by_source() + .returning(move |_, _, _| Ok(Some(matching_ruleset.clone()))); + + let expected_id = crate::identity::generate_public_id("domain-1", "ext-2", "user"); + let expected_id_clone = expected_id.clone(); + + let mut federation_mock = crate::federation::MockFederationProvider::default(); + federation_mock + .expect_get_identity_provider() + .returning(|_, id| { + Ok(Some( + openstack_keystone_core_types::federation::IdentityProviderBuilder::default() + .id(id) + .name("okta") + .enabled(true) + .build() + .unwrap(), + )) + }); + + let mut identity_mock = crate::identity::MockIdentityProvider::default(); + // No SCIM-provisioned user exists yet at the deterministic id. + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + identity_mock + .expect_find_federated_user() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_create_user().returning(move |_, req| { + assert_eq!(req.id.as_deref(), Some(expected_id_clone.as_str())); + Ok( + openstack_keystone_core_types::identity::UserResponseBuilder::default() + .id(req.id.clone().unwrap()) + .domain_id(req.domain_id.clone()) + .name(req.name.clone()) + .enabled(true) + .build() + .unwrap(), + ) + }); + identity_mock + .expect_list_groups_of_user() + .returning(|_, _| Ok(vec![])); + identity_mock + .expect_list_groups() + .returning(|_, _| Ok(vec![])); + + let provider = crate::provider::Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_federation(federation_mock); + let mut cfg = Config::default(); + cfg.mapping.cluster_salt = Some(SecretString::from("test-salt-for-hmac-derivation!")); + let state = get_mocked_state(Some(cfg), Some(provider)).await; + + let service = MappingService::from_driver(mock_backend); + + let mut claims = HashMap::new(); + claims.insert("sub".to_string(), vec!["ext-2".to_string()]); + + let result = service + .authenticate_by_mapping( + &ExecutionContext::internal(&state), + &MappingAuthRequest { + domain_id: Some("domain-1".to_string()), + source: source.clone(), + unique_workload_id: "ext-2".to_string(), + claims, + rule_name: None, + }, + ) + .await + .unwrap(); + + let IdentityInfo::User(user_identity) = result.principal.identity else { + panic!("Expected a User principal"); + }; + assert_eq!(user_identity.user_id, expected_id); + } + #[test] fn test_derive_authz_info_empty() { assert!(derive_authz_info(&[]).is_none()); diff --git a/crates/core/src/mocks.rs b/crates/core/src/mocks.rs index 4d3ea0495..16ff8d8c9 100644 --- a/crates/core/src/mocks.rs +++ b/crates/core/src/mocks.rs @@ -587,6 +587,13 @@ mod identity { user_id: &'a str, ) -> Result; + async fn find_user_by_name_ci<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError>; + async fn find_federated_user<'a>( &self, ctx: &ExecutionContext<'a>, @@ -1120,6 +1127,107 @@ mod token { } pub use token::MockTokenProvider; +mod scim_realm { + use super::*; + + use openstack_keystone_core_types::scim::*; + + use crate::scim_realm::{ScimRealmApi, error::ScimRealmProviderError}; + + mock! { + pub ScimRealmProvider {} + + #[async_trait] + impl ScimRealmApi for ScimRealmProvider { + async fn create_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + data: ScimRealmResourceCreate, + ) -> Result; + + async fn get_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + ) -> Result, ScimRealmProviderError>; + + async fn list_realms<'a>( + &self, + ctx: &ExecutionContext<'a>, + params: &ScimRealmResourceListParameters, + ) -> Result, ScimRealmProviderError>; + + async fn update_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + data: ScimRealmResourceUpdate, + ) -> Result; + } + } +} +pub use scim_realm::MockScimRealmProvider; + +mod scim_resource { + use super::*; + + use openstack_keystone_core_types::scim::*; + + use crate::scim_resource::{ScimResourceApi, error::ScimResourceProviderError}; + + mock! { + pub ScimResourceProvider {} + + #[async_trait] + impl ScimResourceApi for ScimResourceProvider { + async fn create_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + data: ScimResourceIndexCreate, + ) -> Result; + + async fn get_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + ) -> Result, ScimResourceProviderError>; + + async fn get_index_by_external_id<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + external_id: &'a str, + ) -> Result, ScimResourceProviderError>; + + async fn list_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + ) -> Result, ScimResourceProviderError>; + + async fn update_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + data: ScimResourceIndexUpdate, + ) -> Result; + } + } +} +pub use scim_resource::MockScimResourceProvider; + mod trust { use super::*; diff --git a/crates/core/src/plugin_manager.rs b/crates/core/src/plugin_manager.rs index ca26118e8..5a226a0c8 100644 --- a/crates/core/src/plugin_manager.rs +++ b/crates/core/src/plugin_manager.rs @@ -49,6 +49,10 @@ use crate::revoke::RevokeProviderError; use crate::revoke::backend::RevokeBackend; use crate::role::RoleProviderError; use crate::role::backend::RoleBackend; +use crate::scim_realm::backend::ScimRealmBackend; +use crate::scim_realm::error::ScimRealmProviderError; +use crate::scim_resource::backend::ScimResourceBackend; +use crate::scim_resource::error::ScimResourceProviderError; use crate::token::TokenProviderError; use crate::token::backend::TokenBackend; use crate::token::backend::TokenRestrictionBackend; @@ -226,6 +230,32 @@ pub trait PluginManagerApi { name: S, ) -> Result<&Arc, RoleProviderError>; + /// Get registered SCIM realm backend. + /// + /// # Parameters + /// - `name`: The name of the backend to retrieve. + /// + /// # Returns + /// - `Ok(&Arc)` if found, otherwise + /// `Err(ScimRealmProviderError)`. + fn get_scim_realm_backend>( + &self, + name: S, + ) -> Result<&Arc, ScimRealmProviderError>; + + /// Get registered SCIM resource ownership index backend. + /// + /// # Parameters + /// - `name`: The name of the backend to retrieve. + /// + /// # Returns + /// - `Ok(&Arc)` if found, otherwise + /// `Err(ScimResourceProviderError)`. + fn get_scim_resource_backend>( + &self, + name: S, + ) -> Result<&Arc, ScimResourceProviderError>; + /// Get registered token backend. /// /// # Parameters @@ -388,6 +418,28 @@ pub trait PluginManagerApi { /// - `plugin`: The backend implementation. fn register_role_backend>(&mut self, name: S, plugin: Arc); + /// Register SCIM realm backend. + /// + /// # Parameters + /// - `name`: The name to register the backend under. + /// - `plugin`: The backend implementation. + fn register_scim_realm_backend>( + &mut self, + name: S, + plugin: Arc, + ); + + /// Register SCIM resource ownership index backend. + /// + /// # Parameters + /// - `name`: The name to register the backend under. + /// - `plugin`: The backend implementation. + fn register_scim_resource_backend>( + &mut self, + name: S, + plugin: Arc, + ); + /// Register token backend. /// /// # Parameters diff --git a/crates/core/src/provider.rs b/crates/core/src/provider.rs index 0e9b0131a..4f7a99434 100644 --- a/crates/core/src/provider.rs +++ b/crates/core/src/provider.rs @@ -65,6 +65,12 @@ use crate::revoke::RevokeApi; use crate::role::MockRoleProvider; use crate::role::RoleApi; #[cfg(any(test, feature = "mock"))] +use crate::scim_realm::MockScimRealmProvider; +use crate::scim_realm::ScimRealmApi; +#[cfg(any(test, feature = "mock"))] +use crate::scim_resource::MockScimResourceProvider; +use crate::scim_resource::ScimResourceApi; +#[cfg(any(test, feature = "mock"))] use crate::token::MockTokenProvider; use crate::token::TokenApi; #[cfg(any(test, feature = "mock"))] @@ -101,6 +107,10 @@ pub struct Provider { revoke: Box, /// Role provider. role: Box, + /// SCIM realm provider. + scim_realm: Box, + /// SCIM resource ownership index provider. + scim_resource: Box, /// Token provider. token: Box, /// Trust provider. @@ -190,6 +200,18 @@ impl ProviderBuilder { new } + pub fn mock_scim_realm(self, value: impl ScimRealmApi + 'static) -> Self { + let mut new = self; + new.scim_realm = Some(Box::new(value)); + new + } + + pub fn mock_scim_resource(self, value: impl ScimResourceApi + 'static) -> Self { + let mut new = self; + new.scim_resource = Some(Box::new(value)); + new + } + pub fn mock_token(self, value: impl TokenApi + 'static) -> Self { let mut new = self; new.token = Some(Box::new(value)); @@ -240,6 +262,14 @@ impl Provider { let resource = Box::new(crate::resource::ResourceService::new(cfg, plugin_manager)?); let revoke = Box::new(crate::revoke::RevokeService::new(cfg, plugin_manager)?); let role = Box::new(crate::role::RoleService::new(cfg, plugin_manager)?); + let scim_realm = Box::new(crate::scim_realm::ScimRealmService::new( + cfg, + plugin_manager, + )?); + let scim_resource = Box::new(crate::scim_resource::ScimResourceService::new( + cfg, + plugin_manager, + )?); let token = Box::new(crate::token::TokenService::new(cfg, plugin_manager)?); let trust = Box::new(crate::trust::TrustService::new(cfg, plugin_manager)?); @@ -257,6 +287,8 @@ impl Provider { resource, revoke, role, + scim_realm, + scim_resource, token, trust, }) @@ -279,6 +311,8 @@ impl Provider { .mock_resource(MockResourceProvider::default()) .mock_revoke(MockRevokeProvider::default()) .mock_role(MockRoleProvider::default()) + .mock_scim_realm(MockScimRealmProvider::default()) + .mock_scim_resource(MockScimResourceProvider::default()) .mock_token(MockTokenProvider::default()) .mock_trust(MockTrustProvider::default()) } @@ -348,6 +382,16 @@ impl Provider { &*self.role } + /// Get the SCIM realm provider. + pub fn get_scim_realm_provider(&self) -> &dyn ScimRealmApi { + &*self.scim_realm + } + + /// Get the SCIM resource ownership index provider. + pub fn get_scim_resource_provider(&self) -> &dyn ScimResourceApi { + &*self.scim_resource + } + /// Get the token provider. pub fn get_token_provider(&self) -> &dyn TokenApi { &*self.token diff --git a/crates/core/src/scim_realm/backend.rs b/crates/core/src/scim_realm/backend.rs new file mode 100644 index 000000000..eef3176b2 --- /dev/null +++ b/crates/core/src/scim_realm/backend.rs @@ -0,0 +1,58 @@ +// 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 +//! # SCIM realm provider: Backends. +use async_trait::async_trait; + +use openstack_keystone_core_types::scim::*; + +use crate::keystone::ServiceState; +use crate::scim_realm::error::ScimRealmProviderError; + +/// SCIM realm Backend trait. +/// +/// Backend driver interface expected by the SCIM realm provider. +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub trait ScimRealmBackend: Send + Sync { + /// Register a new SCIM realm. + async fn create( + &self, + state: &ServiceState, + data: ScimRealmResourceCreate, + ) -> Result; + + /// Fetch a realm by its `(domain_id, provider_id)` coordinate. + async fn get<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + ) -> Result, ScimRealmProviderError>; + + /// List realms for a domain. + async fn list( + &self, + state: &ServiceState, + params: &ScimRealmResourceListParameters, + ) -> Result, ScimRealmProviderError>; + + /// Update (or enable/disable) a realm. + async fn update<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + data: ScimRealmResourceUpdate, + ) -> Result; +} diff --git a/crates/core/src/scim_realm/error.rs b/crates/core/src/scim_realm/error.rs new file mode 100644 index 000000000..2e713782c --- /dev/null +++ b/crates/core/src/scim_realm/error.rs @@ -0,0 +1,15 @@ +// 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 +//! # SCIM realm provider error +pub use openstack_keystone_core_types::scim::ScimRealmProviderError; diff --git a/crates/core/src/scim_realm/mod.rs b/crates/core/src/scim_realm/mod.rs new file mode 100644 index 000000000..409857704 --- /dev/null +++ b/crates/core/src/scim_realm/mod.rs @@ -0,0 +1,30 @@ +// 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 +//! # SCIM realm provider (ADR 0024 §2) +//! +//! Registering a SCIM realm is a distinct administrative act from creating +//! an `ApiClientResource` (ADR 0021): the latter authenticates, the former +//! additionally authorizes SCIM Users/Groups resource provisioning for the +//! same `(domain_id, provider_id)` coordinate. See ADR 0024 §2.A. + +pub mod backend; +pub mod error; +mod provider_api; +pub mod service; + +#[cfg(any(test, feature = "mock"))] +pub use crate::mocks::MockScimRealmProvider; +pub use error::ScimRealmProviderError; +pub use provider_api::ScimRealmApi; +pub use service::ScimRealmService; diff --git a/crates/core/src/scim_realm/provider_api.rs b/crates/core/src/scim_realm/provider_api.rs new file mode 100644 index 000000000..7a303320b --- /dev/null +++ b/crates/core/src/scim_realm/provider_api.rs @@ -0,0 +1,58 @@ +// 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 +//! # SCIM realm provider API + +use async_trait::async_trait; + +use openstack_keystone_core_types::scim::*; + +use crate::auth::ExecutionContext; +use crate::scim_realm::error::ScimRealmProviderError; + +/// SCIM realm provider interface (ADR 0024 §2). +#[async_trait] +pub trait ScimRealmApi: Send + Sync { + /// Register a new SCIM realm. + async fn create_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + data: ScimRealmResourceCreate, + ) -> Result; + + /// Fetch a realm by its `(domain_id, provider_id)` coordinate. Used by + /// the Realm Activation Gate (ADR 0024 §2.B) on every SCIM resource + /// request. + async fn get_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + ) -> Result, ScimRealmProviderError>; + + /// List realms registered for a domain. + async fn list_realms<'a>( + &self, + ctx: &ExecutionContext<'a>, + params: &ScimRealmResourceListParameters, + ) -> Result, ScimRealmProviderError>; + + /// Update (including enable/disable) a realm. + async fn update_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + data: ScimRealmResourceUpdate, + ) -> Result; +} diff --git a/crates/core/src/scim_realm/service.rs b/crates/core/src/scim_realm/service.rs new file mode 100644 index 000000000..45ec92a50 --- /dev/null +++ b/crates/core/src/scim_realm/service.rs @@ -0,0 +1,149 @@ +// 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 +//! # SCIM realm provider + +use std::sync::Arc; + +use async_trait::async_trait; + +use openstack_keystone_config::Config; +use openstack_keystone_core_types::events::{Event, EventPayload, Operation}; +use openstack_keystone_core_types::scim::*; + +use crate::auth::ExecutionContext; +use crate::events::AuditDispatchError; +use crate::plugin_manager::PluginManagerApi; +use crate::scim_realm::{ScimRealmApi, backend::ScimRealmBackend, error::ScimRealmProviderError}; + +/// SCIM realm Provider. +pub struct ScimRealmService { + /// Backend driver. + pub(super) backend_driver: Arc, +} + +impl ScimRealmService { + /// Create a new `ScimRealmService`. + pub fn new( + config: &Config, + plugin_manager: &P, + ) -> Result { + let backend_driver = plugin_manager + .get_scim_realm_backend(config.scim_realm.driver.clone())? + .clone(); + Ok(Self { backend_driver }) + } + + /// Create a `ScimRealmService` from a backend driver. + #[cfg(any(test, feature = "mock"))] + pub fn from_driver(driver: I) -> Self { + Self { + backend_driver: Arc::new(driver), + } + } +} + +#[async_trait] +impl ScimRealmApi for ScimRealmService { + async fn create_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + data: ScimRealmResourceCreate, + ) -> Result { + let provider_id = data.provider_id.clone(); + if let Some(vsc) = ctx.ctx() { + let backend_driver = &self.backend_driver; + crate::audited_op! { + dispatcher: &ctx.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Create, + EventPayload::ScimRealm { provider_id: provider_id.clone() }, + ), + operation: async { + backend_driver.create(ctx.state(), data).await + }, + on_audit_error: |_: AuditDispatchError| ScimRealmProviderError::Driver { source: "audit dispatch failed".into() }, + } + } else { + let created = self.backend_driver.create(ctx.state(), data).await?; + ctx.state() + .event_dispatcher + .emit(Event::new( + Operation::Create, + EventPayload::ScimRealm { provider_id }, + )) + .await; + Ok(created) + } + } + + async fn get_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + ) -> Result, ScimRealmProviderError> { + self.backend_driver + .get(ctx.state(), domain_id, provider_id) + .await + } + + async fn list_realms<'a>( + &self, + ctx: &ExecutionContext<'a>, + params: &ScimRealmResourceListParameters, + ) -> Result, ScimRealmProviderError> { + self.backend_driver.list(ctx.state(), params).await + } + + async fn update_realm<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + data: ScimRealmResourceUpdate, + ) -> Result { + if let Some(vsc) = ctx.ctx() { + let backend_driver = &self.backend_driver; + let data_clone = data.clone(); + crate::audited_op! { + dispatcher: &ctx.state().event_dispatcher, + ctx: vsc, + event: Event::new( + Operation::Update, + EventPayload::ScimRealm { provider_id: provider_id.to_string() }, + ), + operation: async { + backend_driver.update(ctx.state(), domain_id, provider_id, data_clone).await + }, + on_audit_error: |_: AuditDispatchError| ScimRealmProviderError::Driver { source: "audit dispatch failed".into() }, + } + } else { + let updated = self + .backend_driver + .update(ctx.state(), domain_id, provider_id, data) + .await?; + ctx.state() + .event_dispatcher + .emit(Event::new( + Operation::Update, + EventPayload::ScimRealm { + provider_id: provider_id.to_string(), + }, + )) + .await; + Ok(updated) + } + } +} diff --git a/crates/core/src/scim_resource/backend.rs b/crates/core/src/scim_resource/backend.rs new file mode 100644 index 000000000..aab80d569 --- /dev/null +++ b/crates/core/src/scim_resource/backend.rs @@ -0,0 +1,77 @@ +// 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 +//! # SCIM resource index provider: Backends. +use async_trait::async_trait; + +use openstack_keystone_core_types::scim::*; + +use crate::keystone::ServiceState; +use crate::scim_resource::error::ScimResourceProviderError; + +/// SCIM resource ownership index Backend trait. +/// +/// Backend driver interface expected by the SCIM resource index provider +/// (ADR 0024 §3.A). +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub trait ScimResourceBackend: Send + Sync { + /// Anchor a newly-created SCIM resource. + async fn create( + &self, + state: &ServiceState, + data: ScimResourceIndexCreate, + ) -> Result; + + /// Fetch the ownership anchor for `(domain_id, provider_id, type, id)` + /// (ADR 0024 §3.C step 1). + async fn get<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + ) -> Result, ScimResourceProviderError>; + + /// Fetch the ownership anchor by its realm-scoped `externalId` (ADR 0024 + /// §3.C last paragraph). + async fn get_by_external_id<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + external_id: &'a str, + ) -> Result, ScimResourceProviderError>; + + /// List all anchors owned by a realm for a given resource type. + async fn list<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + ) -> Result, ScimResourceProviderError>; + + /// Apply a partial update (e.g. soft-disable, `externalId` change). + async fn update<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + data: ScimResourceIndexUpdate, + ) -> Result; +} diff --git a/crates/core/src/scim_resource/error.rs b/crates/core/src/scim_resource/error.rs new file mode 100644 index 000000000..cfcbd6975 --- /dev/null +++ b/crates/core/src/scim_resource/error.rs @@ -0,0 +1,15 @@ +// 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 +//! # SCIM resource index provider error +pub use openstack_keystone_core_types::scim::ScimResourceProviderError; diff --git a/crates/core/src/scim_resource/mod.rs b/crates/core/src/scim_resource/mod.rs new file mode 100644 index 000000000..7aac9bbf8 --- /dev/null +++ b/crates/core/src/scim_resource/mod.rs @@ -0,0 +1,28 @@ +// 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 +//! # SCIM resource ownership index provider (ADR 0024 §3.A) +//! +//! Tracks which realm (`provider_id`) owns each SCIM-provisioned `User`/ +//! `Group`, backing the Ownership Fencing Algorithm (§3.C). + +pub mod backend; +pub mod error; +mod provider_api; +pub mod service; + +#[cfg(any(test, feature = "mock"))] +pub use crate::mocks::MockScimResourceProvider; +pub use error::ScimResourceProviderError; +pub use provider_api::ScimResourceApi; +pub use service::ScimResourceService; diff --git a/crates/core/src/scim_resource/provider_api.rs b/crates/core/src/scim_resource/provider_api.rs new file mode 100644 index 000000000..58ab5c8fd --- /dev/null +++ b/crates/core/src/scim_resource/provider_api.rs @@ -0,0 +1,78 @@ +// 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 +//! # SCIM resource index provider API + +use async_trait::async_trait; + +use openstack_keystone_core_types::scim::*; + +use crate::auth::ExecutionContext; +use crate::scim_resource::error::ScimResourceProviderError; + +/// SCIM resource ownership index provider interface (ADR 0024 §3.A). +/// +/// Bookkeeping-only: this provider never itself emits CADF audit events — +/// the SCIM resource handlers (Users/Groups) that call it are responsible +/// for auditing the actual Identity mutation they perform alongside the +/// index write. +#[async_trait] +pub trait ScimResourceApi: Send + Sync { + /// Anchor a newly-created SCIM resource. + async fn create_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + data: ScimResourceIndexCreate, + ) -> Result; + + /// Fetch the ownership anchor for `(domain_id, provider_id, type, id)` + /// (ADR 0024 §3.C step 1: the Ownership Fencing Algorithm). + async fn get_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + ) -> Result, ScimResourceProviderError>; + + /// Fetch the ownership anchor by its realm-scoped `externalId`. + async fn get_index_by_external_id<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + external_id: &'a str, + ) -> Result, ScimResourceProviderError>; + + /// List all anchors owned by a realm for a given resource type. + async fn list_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + ) -> Result, ScimResourceProviderError>; + + /// Apply a partial update (e.g. soft-disable, `externalId` change). + async fn update_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + data: ScimResourceIndexUpdate, + ) -> Result; +} diff --git a/crates/core/src/scim_resource/service.rs b/crates/core/src/scim_resource/service.rs new file mode 100644 index 000000000..03bde9f2d --- /dev/null +++ b/crates/core/src/scim_resource/service.rs @@ -0,0 +1,136 @@ +// 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 +//! # SCIM resource index provider + +use std::sync::Arc; + +use async_trait::async_trait; + +use openstack_keystone_config::Config; +use openstack_keystone_core_types::scim::*; + +use crate::auth::ExecutionContext; +use crate::plugin_manager::PluginManagerApi; +use crate::scim_resource::{ + ScimResourceApi, backend::ScimResourceBackend, error::ScimResourceProviderError, +}; + +/// SCIM resource index Provider. +pub struct ScimResourceService { + /// Backend driver. + pub(super) backend_driver: Arc, +} + +impl ScimResourceService { + /// Create a new `ScimResourceService`. + pub fn new( + config: &Config, + plugin_manager: &P, + ) -> Result { + let backend_driver = plugin_manager + .get_scim_resource_backend(config.scim_resource.driver.clone())? + .clone(); + Ok(Self { backend_driver }) + } + + /// Create a `ScimResourceService` from a backend driver. + #[cfg(any(test, feature = "mock"))] + pub fn from_driver(driver: I) -> Self { + Self { + backend_driver: Arc::new(driver), + } + } +} + +#[async_trait] +impl ScimResourceApi for ScimResourceService { + async fn create_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + data: ScimResourceIndexCreate, + ) -> Result { + self.backend_driver.create(ctx.state(), data).await + } + + async fn get_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + ) -> Result, ScimResourceProviderError> { + self.backend_driver + .get( + ctx.state(), + domain_id, + provider_id, + resource_type, + keystone_id, + ) + .await + } + + async fn get_index_by_external_id<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + external_id: &'a str, + ) -> Result, ScimResourceProviderError> { + self.backend_driver + .get_by_external_id( + ctx.state(), + domain_id, + provider_id, + resource_type, + external_id, + ) + .await + } + + async fn list_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + ) -> Result, ScimResourceProviderError> { + self.backend_driver + .list(ctx.state(), domain_id, provider_id, resource_type) + .await + } + + async fn update_index<'a>( + &self, + ctx: &ExecutionContext<'a>, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + data: ScimResourceIndexUpdate, + ) -> Result { + self.backend_driver + .update( + ctx.state(), + domain_id, + provider_id, + resource_type, + keystone_id, + data, + ) + .await + } +} diff --git a/crates/identity-driver-sql/src/lib.rs b/crates/identity-driver-sql/src/lib.rs index bed7f5a03..78344e3a6 100644 --- a/crates/identity-driver-sql/src/lib.rs +++ b/crates/identity-driver-sql/src/lib.rs @@ -315,6 +315,23 @@ impl IdentityBackend for SqlBackend { Ok(user::get_user_domain_id(&state.db, user_id).await?) } + /// Find the `user_id` of any user in `domain_id` whose name matches + /// `name`, case-insensitively (ADR 0024 §3.D). + /// + /// # Parameters + /// - `state`: The service state. + /// - `domain_id`: The domain to search within. + /// - `name`: The name to match, case-insensitively. + #[tracing::instrument(skip(self, state))] + async fn find_user_by_name_ci<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + name: &'a str, + ) -> Result, IdentityProviderError> { + user::find_by_name_ci(&state.db, domain_id, name).await + } + /// Find federated user by IDP and Unique ID /// /// # Parameters diff --git a/crates/identity-driver-sql/src/user.rs b/crates/identity-driver-sql/src/user.rs index d6d6125d7..b6cf9bc56 100644 --- a/crates/identity-driver-sql/src/user.rs +++ b/crates/identity-driver-sql/src/user.rs @@ -22,6 +22,7 @@ use crate::entity::user as db_user; mod create; mod delete; +mod find_by_name; mod get; mod list; mod set; @@ -29,6 +30,7 @@ mod update; pub use create::create; pub use delete::delete; +pub use find_by_name::find_by_name_ci; pub(super) use get::get_main_entry; pub use get::{get, get_user_domain_id}; pub use list::list; diff --git a/crates/identity-driver-sql/src/user/create.rs b/crates/identity-driver-sql/src/user/create.rs index 0a6460816..c980bdfac 100644 --- a/crates/identity-driver-sql/src/user/create.rs +++ b/crates/identity-driver-sql/src/user/create.rs @@ -26,11 +26,13 @@ use openstack_keystone_core_types::identity::*; use crate::entity::{federated_user as db_federated_user, user as db_user}; use crate::federated_user::MergeFederatedUserData; use crate::local_user::MergeLocalUserData; +use crate::nonlocal_user::MergeNonlocalUserData; use crate::password::MergePasswordData; use crate::user::MergeUserData; use crate::federated_user; use crate::local_user; +use crate::nonlocal_user; use crate::password; use crate::user_option; @@ -181,10 +183,15 @@ pub async fn create( } } response_builder.merge_federated_user_data(federated_entities); + } else if user.user_type == UserType::NonLocal { + if user.password.is_some() { + return Err(IdentityProviderError::Conflict( + "password cannot be set on a nonlocal (externally managed) user".to_string(), + )); + } + let nonlocal_user = nonlocal_user::create(&txn, &main_user, user.name.clone()).await?; + response_builder.merge_nonlocal_user_data(&nonlocal_user); } else { - // When the user is not a federated one we can only assume it is a local user. - // For creating nonlocal user or service account dedicated API should be - // used. let local_user = local_user::create(conf, &txn, &main_user, &user).await?; response_builder.merge_local_user_data(&local_user); @@ -221,7 +228,8 @@ mod tests { use crate::{ federated_user::tests::get_federated_user_mock, local_user::tests::get_local_user_mock, - password::tests::get_password_mock, user::tests::get_user_mock, + nonlocal_user::tests::get_nonlocal_user_mock, password::tests::get_password_mock, + user::tests::get_user_mock, }; #[test] @@ -429,4 +437,40 @@ mod tests { assert_eq!(sot.name, "foo_domain"); assert_eq!(sot.options, user_opts); } + + #[tokio::test] + async fn test_create_nonlocal() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_user_mock("1")]]) + .append_query_results([vec![get_nonlocal_user_mock("1")]]) + .into_connection(); + let req = UserCreateBuilder::default() + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .user_type(UserType::NonLocal) + .build() + .unwrap(); + let sot = create(&Config::default(), &db, req).await.unwrap(); + assert_eq!(sot.name, "foo"); + } + + #[tokio::test] + async fn test_create_nonlocal_rejects_password() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_user_mock("1")]]) + .into_connection(); + let req = UserCreateBuilder::default() + .domain_id("did") + .id("1") + .name("foo") + .enabled(true) + .user_type(UserType::NonLocal) + .password("hunter2") + .build() + .unwrap(); + let result = create(&Config::default(), &db, req).await; + assert!(matches!(result, Err(IdentityProviderError::Conflict(_)))); + } } diff --git a/crates/identity-driver-sql/src/user/find_by_name.rs b/crates/identity-driver-sql/src/user/find_by_name.rs new file mode 100644 index 000000000..82328d8bb --- /dev/null +++ b/crates/identity-driver-sql/src/user/find_by_name.rs @@ -0,0 +1,110 @@ +// 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 +//! Domain-wide, case-insensitive `userName` collision check (ADR 0024 +//! §3.D), used by SCIM `POST /Users` to reject a create whenever *any* +//! existing `User` in the domain already has this name — regardless of +//! whether that user was created by this SCIM realm, another realm, or +//! manually via `/v3/users`. +//! +//! Scoped to `local_user` and `nonlocal_user` (both carry `domain_id` +//! directly). Federated users are deliberately not checked here: they are +//! provisioned through IdP federation (`idp_id` + `unique_id`), not +//! `userName` assignment, so they are not a realistic source of the +//! collision this check guards against, and checking them would require an +//! extra join against `user` for `domain_id`. This is a live pre-flight +//! query, not a transactional guarantee — see ADR 0024 §3.D for the +//! documented TOCTOU trade-off. + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; +use sea_orm::sea_query::{Expr, Func}; + +use openstack_keystone_core::error::DbContextExt; +use openstack_keystone_core::identity::IdentityProviderError; + +use crate::entity::{local_user as db_local_user, nonlocal_user as db_nonlocal_user}; + +/// Find the `user_id` of any local or nonlocal user in `domain_id` whose +/// name matches `name`, case-insensitively. +#[tracing::instrument(skip(db))] +pub async fn find_by_name_ci( + db: &DatabaseConnection, + domain_id: &str, + name: &str, +) -> Result, IdentityProviderError> { + let name_lower = name.to_lowercase(); + + if let Some(local) = db_local_user::Entity::find() + .filter(db_local_user::Column::DomainId.eq(domain_id)) + .filter( + Expr::expr(Func::lower(Expr::col(db_local_user::Column::Name))).eq(name_lower.clone()), + ) + .one(db) + .await + .context("checking local_user name collision")? + { + return Ok(Some(local.user_id)); + } + + if let Some(nonlocal) = db_nonlocal_user::Entity::find() + .filter(db_nonlocal_user::Column::DomainId.eq(domain_id)) + .filter(Expr::expr(Func::lower(Expr::col(db_nonlocal_user::Column::Name))).eq(name_lower)) + .one(db) + .await + .context("checking nonlocal_user name collision")? + { + return Ok(Some(nonlocal.user_id)); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase}; + + use super::*; + use crate::local_user::tests::get_local_user_mock; + + #[tokio::test] + async fn test_find_by_name_ci_matches_local_user() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_local_user_mock("1")]]) + .into_connection(); + + let found = find_by_name_ci(&db, "foo_domain", "APPLE CAKE") + .await + .unwrap(); + assert_eq!(found, Some("1".to_string())); + + // Verify the comparison is actually done case-insensitively via + // LOWER(), against the lowercased input value. + let log = db.into_transaction_log(); + let sql = &log[0].statements()[0].sql; + assert!(sql.contains("LOWER"), "query must lower() the name column"); + } + + #[tokio::test] + async fn test_find_by_name_ci_no_match_falls_through_to_nonlocal() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([Vec::::new()]) + .append_query_results([Vec::::new()]) + .into_connection(); + + let found = find_by_name_ci(&db, "foo_domain", "nobody").await.unwrap(); + assert_eq!(found, None); + assert_eq!(db.into_transaction_log().len(), 2, "both tables checked"); + } +} diff --git a/crates/key-repository/src/lib.rs b/crates/key-repository/src/lib.rs index fc51746b0..2a408e6c9 100644 --- a/crates/key-repository/src/lib.rs +++ b/crates/key-repository/src/lib.rs @@ -123,8 +123,8 @@ impl KeyRepository { /// /// # Errors /// - [`KeyRepositoryError::KeysMissing`] if the repository is empty. - /// - [`KeyRepositoryError::NullKeyDetected`] if any key decodes to the - /// Null Key and `insecure_allow_null_key` is `false`. + /// - [`KeyRepositoryError::NullKeyDetected`] if any key decodes to the Null + /// Key and `insecure_allow_null_key` is `false`. pub async fn load_keys( &self, insecure_allow_null_key: bool, @@ -221,15 +221,15 @@ impl KeyRepository { .checked_add(1) .ok_or(KeyRepositoryError::IndexOverflow)?; - // 1. Promote: move staged `0` -> new_primary_idx. The outgoing - // primary keeps its own entry and stays active for decryption. + // 1. Promote: move staged `0` -> new_primary_idx. The outgoing primary keeps + // its own entry and stays active for decryption. self.source.promote(0, new_primary_idx, &staged).await?; // 2. Stage a fresh key `0` for the next rotation cycle. self.setup().await?; - // 3. Prune beyond max_active_keys, oldest (smallest positive index) - // first. `0` (staged) is never pruned. + // 3. Prune beyond max_active_keys, oldest (smallest positive index) first. `0` + // (staged) is never pruned. let mut remaining = self.source.load().await?; remaining.remove(&0); while remaining.len() + 1 > self.max_active_keys { diff --git a/crates/keystone/Cargo.toml b/crates/keystone/Cargo.toml index a3ac4c378..33b69703e 100644 --- a/crates/keystone/Cargo.toml +++ b/crates/keystone/Cargo.toml @@ -53,6 +53,7 @@ openstack-keystone-idmapping-driver-sql.workspace = true openstack-keystone-resource-driver-sql.workspace = true openstack-keystone-revoke-driver-sql.workspace = true openstack-keystone-role-driver-sql.workspace = true +openstack-keystone-scim-driver-raft.workspace = true openstack-keystone-token-driver-fernet.workspace = true openstack-keystone-token-restriction-driver-sql.workspace = true openstack-keystone-trust-driver-sql.workspace = true diff --git a/crates/keystone/src/api/v4/mod.rs b/crates/keystone/src/api/v4/mod.rs index ea7057a97..89f2499e1 100644 --- a/crates/keystone/src/api/v4/mod.rs +++ b/crates/keystone/src/api/v4/mod.rs @@ -34,6 +34,7 @@ pub mod group; pub mod mapping; pub mod role; pub mod role_assignment; +pub mod scim_realm; pub mod token; pub mod user; @@ -47,6 +48,7 @@ use crate::api::types::*; (path = "federation", api = federation::ApiDoc), (path = "k8s_auth", api = k8s_auth::ApiDoc), (path = "mappings", api = mapping::ApiDoc), + (path = "scim_realms", api = scim_realm::ApiDoc), (path = "tokens", api = token::ApiDoc), (path = "users", api = user::ApiDoc), ), @@ -63,6 +65,7 @@ pub(super) fn openapi_router() -> OpenApiRouter { .nest("/k8s_auth", k8s_auth::openapi_router()) .nest("/role_assignments", role_assignment::openapi_router()) .nest("/roles", role::openapi_router()) + .nest("/scim_realms", scim_realm::openapi_router()) .nest("/tokens", token::openapi_router()) .nest("/users", user::openapi_router()) .routes(routes!(version)) diff --git a/crates/keystone/src/api/v4/scim_realm.rs b/crates/keystone/src/api/v4/scim_realm.rs new file mode 100644 index 000000000..38d2c5186 --- /dev/null +++ b/crates/keystone/src/api/v4/scim_realm.rs @@ -0,0 +1,43 @@ +// 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 +//! SCIM realm CRUD (ADR 0024 §2.A). Registering a realm is an explicit +//! administrative act, separate from creating an `ApiClientResource` (ADR +//! 0021): the latter authenticates, this authorizes SCIM Users/Groups +//! resource provisioning for the same `(domain_id, provider_id)` +//! coordinate. + +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; + +use crate::keystone::ServiceState; + +mod create; +mod list; +mod show; +mod update; + +#[derive(OpenApi)] +#[openapi(tags((name="scim_realm", description=r#" +SCIM realm administration. A SCIM realm is the explicit administrative +activation of an `(domain_id, provider_id)` coordinate for SCIM Users/Groups +resource provisioning (ADR 0024). +"#)))] +pub struct ApiDoc; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list::list, create::create)) + .routes(routes!(show::show, update::update)) +} diff --git a/crates/keystone/src/api/v4/scim_realm/create.rs b/crates/keystone/src/api/v4/scim_realm/create.rs new file mode 100644 index 000000000..c46a55e55 --- /dev/null +++ b/crates/keystone/src/api/v4/scim_realm/create.rs @@ -0,0 +1,245 @@ +// 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 +//! SCIM realm: create. + +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; +use openstack_keystone_api_types::v4::scim_realm::{ + ScimRealm, ScimRealmCreateRequest, ScimRealmResponse, +}; +use validator::Validate; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +/// Register a new SCIM realm. +#[utoipa::path( + post, + path = "/", + operation_id = "/scim_realm:create", + request_body = ScimRealmCreateRequest, + responses( + (status = CREATED, description = "SCIM realm object", body = ScimRealmResponse), + ), + security(("x-auth" = [])), + tag="scim_realm" +)] +#[tracing::instrument( + name = "api::v4::scim_realm::create", + level = "debug", + skip(state, user_auth), + err(Debug) +)] +pub(super) async fn create( + Auth(user_auth): Auth, + State(state): State, + Json(req): Json, +) -> Result { + req.validate()?; + + state + .policy_enforcer + .enforce( + "identity/scim_realm/create", + &user_auth, + serde_json::json!({"scim_realm": req.scim_realm}), + None, + ) + .await?; + + let exec = ExecutionContext::from_auth(&state, &user_auth); + + // Every SCIM realm must be linked to a real, existing federation + // IdentityProvider — SCIM users are always provisioned as `nonlocal_user` + // shadow identities keyed off this IdP (see ADR 0024 dedup fix), so an + // unresolvable idp_id must not be allowed to create a realm at all. + if state + .provider + .get_federation_provider() + .get_identity_provider(&exec, &req.scim_realm.idp_id) + .await? + .is_none() + { + return Err(KeystoneApiError::NotFound { + resource: "identity_provider".to_string(), + identifier: req.scim_realm.idp_id.clone(), + }); + } + + let res = state + .provider + .get_scim_realm_provider() + .create_realm(&exec, req.into()) + .await?; + + Ok(( + StatusCode::CREATED, + Json(ScimRealmResponse { + scim_realm: ScimRealm::from(res), + }), + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + + use openstack_keystone_api_types::v4::scim_realm::{ + ScimRealmCreate, ScimRealmCreateRequest, ScimRealmResponse, + }; + use openstack_keystone_core_types::federation::IdentityProviderBuilder; + use openstack_keystone_core_types::scim as provider_types; + + use super::super::openapi_router; + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::federation::MockFederationProvider; + use crate::provider::Provider; + use crate::scim_realm::MockScimRealmProvider; + + fn sample_realm_core() -> provider_types::ScimRealmResource { + provider_types::ScimRealmResource { + domain_id: "domain_id".into(), + provider_id: "provider-1".into(), + idp_id: "idp-1".into(), + display_name: "Okta - Employees".into(), + enabled: true, + created_at: 0, + updated_at: 0, + } + } + + fn sample_create() -> ScimRealmCreateRequest { + ScimRealmCreateRequest { + scim_realm: ScimRealmCreate { + domain_id: "domain_id".into(), + provider_id: "provider-1".into(), + idp_id: "idp-1".into(), + display_name: "Okta - Employees".into(), + }, + } + } + + #[tokio::test] + async fn test_create() { + let vsc = test_fixture_scoped(); + let mut mock = MockScimRealmProvider::default(); + mock.expect_create_realm() + .returning(|_, _| Ok(sample_realm_core())); + let mut federation_mock = MockFederationProvider::default(); + federation_mock + .expect_get_identity_provider() + .returning(|_, id| { + Ok(Some( + IdentityProviderBuilder::default() + .id(id) + .name("okta") + .build() + .unwrap(), + )) + }); + let provider = Provider::mocked_builder() + .mock_scim_realm(mock) + .mock_federation(federation_mock); + + let state = get_mocked_state(provider, true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = sample_create(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/") + .header(header::CONTENT_TYPE, "application/json") + .extension(vsc) + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ScimRealmResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.scim_realm.provider_id, "provider-1"); + } + + #[tokio::test] + async fn test_create_policy_denied() { + let vsc = test_fixture_scoped(); + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = sample_create(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/") + .header(header::CONTENT_TYPE, "application/json") + .extension(vsc) + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_create_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = sample_create(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .uri("/") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v4/scim_realm/list.rs b/crates/keystone/src/api/v4/scim_realm/list.rs new file mode 100644 index 000000000..8d3df6b6a --- /dev/null +++ b/crates/keystone/src/api/v4/scim_realm/list.rs @@ -0,0 +1,194 @@ +// 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 +//! SCIM realm: list. + +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, +}; + +use openstack_keystone_api_types::v4::scim_realm::{ + ScimRealm, ScimRealmList, ScimRealmListParameters, +}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +/// List SCIM realms for a domain. +#[utoipa::path( + get, + path = "/", + operation_id = "/scim_realm:list", + params(ScimRealmListParameters), + responses( + (status = OK, description = "List of SCIM realms", body = ScimRealmList), + ), + security(("x-auth" = [])), + tag="scim_realm" +)] +#[tracing::instrument( + name = "api::v4::scim_realm::list", + level = "debug", + skip(state, user_auth), + err(Debug) +)] +pub(super) async fn list( + Auth(user_auth): Auth, + Query(query): Query, + State(state): State, +) -> Result { + state + .policy_enforcer + .enforce( + "identity/scim_realm/list", + &user_auth, + serde_json::json!({"scim_realm": query}), + None, + ) + .await?; + + let params = query.into(); + let realms: Vec = state + .provider + .get_scim_realm_provider() + .list_realms(&ExecutionContext::from_auth(&state, &user_auth), ¶ms) + .await? + .into_iter() + .map(Into::into) + .collect(); + + Ok(( + StatusCode::OK, + Json(ScimRealmList { + scim_realms: realms, + links: None, + }), + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + + use openstack_keystone_api_types::v4::scim_realm::ScimRealmList; + use openstack_keystone_core_types::scim as provider_types; + + use super::super::openapi_router; + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::provider::Provider; + use crate::scim_realm::MockScimRealmProvider; + + fn sample_realm_core() -> provider_types::ScimRealmResource { + provider_types::ScimRealmResource { + domain_id: "domain_id".into(), + provider_id: "provider-1".into(), + idp_id: "idp-1".into(), + display_name: "Okta - Employees".into(), + enabled: true, + created_at: 0, + updated_at: 0, + } + } + + #[tokio::test] + async fn test_list() { + let vsc = test_fixture_scoped(); + let mut mock = MockScimRealmProvider::default(); + mock.expect_list_realms() + .returning(|_, _| Ok(vec![sample_realm_core()])); + let provider = Provider::mocked_builder().mock_scim_realm(mock); + + let state = get_mocked_state(provider, true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain_id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ScimRealmList = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.scim_realms.len(), 1); + assert_eq!(res.scim_realms[0].provider_id, "provider-1"); + } + + #[tokio::test] + async fn test_list_policy_denied() { + let vsc = test_fixture_scoped(); + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain_id") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_list_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain_id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v4/scim_realm/show.rs b/crates/keystone/src/api/v4/scim_realm/show.rs new file mode 100644 index 000000000..ea81f845a --- /dev/null +++ b/crates/keystone/src/api/v4/scim_realm/show.rs @@ -0,0 +1,200 @@ +// 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 +//! SCIM realm: show. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use openstack_keystone_api_types::v4::scim_realm::{ScimRealm, ScimRealmResponse}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +/// Show a SCIM realm by its `(domain_id, provider_id)` coordinate. +#[utoipa::path( + get, + path = "/{domain_id}/{provider_id}", + operation_id = "/scim_realm:show", + params( + ("domain_id" = String, Path, description = "Domain ID"), + ("provider_id" = String, Path, description = "Realm provider_id"), + ), + responses( + (status = OK, description = "SCIM realm object", body = ScimRealmResponse), + ), + security(("x-auth" = [])), + tag="scim_realm" +)] +#[tracing::instrument( + name = "api::v4::scim_realm::show", + level = "debug", + skip(state, user_auth), + err(Debug) +)] +pub(super) async fn show( + Auth(user_auth): Auth, + Path((domain_id, provider_id)): Path<(String, String)>, + State(state): State, +) -> Result { + let current = state + .provider + .get_scim_realm_provider() + .get_realm( + &ExecutionContext::from_auth(&state, &user_auth), + &domain_id, + &provider_id, + ) + .await? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "scim_realm".into(), + identifier: provider_id.clone(), + })?; + + state + .policy_enforcer + .enforce( + "identity/scim_realm/show", + &user_auth, + serde_json::json!({"scim_realm": null}), + Some(serde_json::json!({"scim_realm": ScimRealm::from(current.clone())})), + ) + .await?; + + Ok(( + StatusCode::OK, + Json(ScimRealmResponse { + scim_realm: ScimRealm::from(current), + }), + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + + use openstack_keystone_api_types::v4::scim_realm::ScimRealmResponse; + use openstack_keystone_core_types::scim as provider_types; + + use super::super::openapi_router; + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::provider::Provider; + use crate::scim_realm::MockScimRealmProvider; + + fn sample_realm_core() -> provider_types::ScimRealmResource { + provider_types::ScimRealmResource { + domain_id: "domain_id".into(), + provider_id: "provider-1".into(), + idp_id: "idp-1".into(), + display_name: "Okta - Employees".into(), + enabled: true, + created_at: 0, + updated_at: 0, + } + } + + #[tokio::test] + async fn test_show() { + let vsc = test_fixture_scoped(); + let mut mock = MockScimRealmProvider::default(); + mock.expect_get_realm() + .returning(|_, _, _| Ok(Some(sample_realm_core()))); + let provider = Provider::mocked_builder().mock_scim_realm(mock); + + let state = get_mocked_state(provider, true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/domain_id/provider-1") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ScimRealmResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.scim_realm.provider_id, "provider-1"); + } + + #[tokio::test] + async fn test_show_not_found() { + let vsc = test_fixture_scoped(); + let mut mock = MockScimRealmProvider::default(); + mock.expect_get_realm().returning(|_, _, _| Ok(None)); + let provider = Provider::mocked_builder().mock_scim_realm(mock); + + let state = get_mocked_state(provider, true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/domain_id/nonexistent") + .extension(vsc) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_show_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/domain_id/provider-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/api/v4/scim_realm/update.rs b/crates/keystone/src/api/v4/scim_realm/update.rs new file mode 100644 index 000000000..b36f5c115 --- /dev/null +++ b/crates/keystone/src/api/v4/scim_realm/update.rs @@ -0,0 +1,260 @@ +// 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 +//! SCIM realm: update (including the enable/disable toggle, ADR 0024 §2.B). + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use openstack_keystone_api_types::v4::scim_realm::{ + ScimRealm, ScimRealmResponse, ScimRealmUpdateRequest, +}; +use validator::Validate; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use openstack_keystone_core::auth::ExecutionContext; + +/// Update a SCIM realm (including enable/disable). +#[utoipa::path( + put, + path = "/{domain_id}/{provider_id}", + operation_id = "/scim_realm:update", + params( + ("domain_id" = String, Path, description = "Domain ID"), + ("provider_id" = String, Path, description = "Realm provider_id"), + ), + request_body = ScimRealmUpdateRequest, + responses( + (status = OK, description = "SCIM realm object", body = ScimRealmResponse), + ), + security(("x-auth" = [])), + tag="scim_realm" +)] +#[tracing::instrument( + name = "api::v4::scim_realm::update", + level = "debug", + skip(state, user_auth), + err(Debug) +)] +pub(super) async fn update( + Auth(user_auth): Auth, + Path((domain_id, provider_id)): Path<(String, String)>, + State(state): State, + Json(req): Json, +) -> Result { + req.validate()?; + + let exec = ExecutionContext::from_auth(&state, &user_auth); + + let current = state + .provider + .get_scim_realm_provider() + .get_realm(&exec, &domain_id, &provider_id) + .await? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "scim_realm".into(), + identifier: provider_id.clone(), + })?; + + // If the realm's IdP link is being changed, it must resolve to a real + // identity provider — see ADR 0024 dedup fix rationale in create.rs. + if let Some(idp_id) = &req.scim_realm.idp_id + && state + .provider + .get_federation_provider() + .get_identity_provider(&exec, idp_id) + .await? + .is_none() + { + return Err(KeystoneApiError::NotFound { + resource: "identity_provider".to_string(), + identifier: idp_id.clone(), + }); + } + + state + .policy_enforcer + .enforce( + "identity/scim_realm/disable", + &user_auth, + serde_json::json!({"scim_realm": req.scim_realm}), + Some(serde_json::json!({"scim_realm": ScimRealm::from(current.clone())})), + ) + .await?; + + let res = state + .provider + .get_scim_realm_provider() + .update_realm(&exec, &domain_id, &provider_id, req.scim_realm.into()) + .await?; + + Ok(( + StatusCode::OK, + Json(ScimRealmResponse { + scim_realm: ScimRealm::from(res), + }), + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + + use openstack_keystone_api_types::v4::scim_realm::{ + ScimRealmResponse, ScimRealmUpdate, ScimRealmUpdateRequest, + }; + use openstack_keystone_core_types::scim as provider_types; + + use super::super::openapi_router; + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; + use crate::provider::Provider; + use crate::scim_realm::MockScimRealmProvider; + + fn sample_realm_core() -> provider_types::ScimRealmResource { + provider_types::ScimRealmResource { + domain_id: "domain_id".into(), + provider_id: "provider-1".into(), + idp_id: "idp-1".into(), + display_name: "Okta - Employees".into(), + enabled: true, + created_at: 0, + updated_at: 0, + } + } + + fn sample_disabled_realm_core() -> provider_types::ScimRealmResource { + provider_types::ScimRealmResource { + enabled: false, + ..sample_realm_core() + } + } + + fn sample_update() -> ScimRealmUpdateRequest { + ScimRealmUpdateRequest { + scim_realm: ScimRealmUpdate { + enabled: Some(false), + ..Default::default() + }, + } + } + + #[tokio::test] + async fn test_update_disables_realm() { + let vsc = test_fixture_scoped(); + let mut mock = MockScimRealmProvider::default(); + mock.expect_get_realm() + .returning(|_, _, _| Ok(Some(sample_realm_core()))); + mock.expect_update_realm() + .returning(|_, _, _, _| Ok(sample_disabled_realm_core())); + let provider = Provider::mocked_builder().mock_scim_realm(mock); + + let state = get_mocked_state(provider, true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = sample_update(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PUT") + .uri("/domain_id/provider-1") + .header(header::CONTENT_TYPE, "application/json") + .extension(vsc) + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: ScimRealmResponse = serde_json::from_slice(&body).unwrap(); + assert!(!res.scim_realm.enabled); + } + + #[tokio::test] + async fn test_update_policy_denied() { + let vsc = test_fixture_scoped(); + let mut mock = MockScimRealmProvider::default(); + mock.expect_get_realm() + .returning(|_, _, _| Ok(Some(sample_realm_core()))); + let provider = Provider::mocked_builder().mock_scim_realm(mock); + + let state = get_mocked_state(provider, false, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = sample_update(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PUT") + .uri("/domain_id/provider-1") + .header(header::CONTENT_TYPE, "application/json") + .extension(vsc) + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_update_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = sample_update(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PUT") + .uri("/domain_id/provider-1") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/keystone/src/audit.rs b/crates/keystone/src/audit.rs index 96cfcc059..cb1d29e48 100644 --- a/crates/keystone/src/audit.rs +++ b/crates/keystone/src/audit.rs @@ -93,6 +93,7 @@ pub fn error_variant_name(error: &KeystoneApiError) -> String { KeystoneApiError::Serde { .. } => "BadRequest".to_string(), KeystoneApiError::Other(_) => "InternalServerError".to_string(), KeystoneApiError::TooManyRequests => "TooManyRequests".to_string(), + KeystoneApiError::UnprocessableEntity(_) => "UnprocessableEntity".to_string(), } } diff --git a/crates/keystone/src/lib.rs b/crates/keystone/src/lib.rs index c2ca2fd36..3cf44bab3 100644 --- a/crates/keystone/src/lib.rs +++ b/crates/keystone/src/lib.rs @@ -104,6 +104,8 @@ pub mod resource; pub mod revoke; pub mod role; pub mod scim; +pub mod scim_realm; +pub mod scim_resource; pub mod server; pub mod token; pub mod trust; diff --git a/crates/keystone/src/plugin_manager.rs b/crates/keystone/src/plugin_manager.rs index e29d2c1d3..9ddcdc62b 100644 --- a/crates/keystone/src/plugin_manager.rs +++ b/crates/keystone/src/plugin_manager.rs @@ -51,6 +51,10 @@ use openstack_keystone_core::revoke::RevokeProviderError; use openstack_keystone_core::revoke::backend::RevokeBackend; use openstack_keystone_core::role::RoleProviderError; use openstack_keystone_core::role::backend::RoleBackend; +use openstack_keystone_core::scim_realm::ScimRealmProviderError; +use openstack_keystone_core::scim_realm::backend::ScimRealmBackend; +use openstack_keystone_core::scim_resource::ScimResourceProviderError; +use openstack_keystone_core::scim_resource::backend::ScimResourceBackend; use openstack_keystone_core::token::TokenProviderError; use openstack_keystone_core::token::backend::{TokenBackend, TokenRestrictionBackend}; use openstack_keystone_core::trust::TrustProviderError; @@ -88,6 +92,10 @@ pub struct PluginManager { revoke_backends: HashMap>, /// Role backend plugins. role_backends: HashMap>, + /// SCIM realm backend plugins. + scim_realm_backends: HashMap>, + /// SCIM resource ownership index backend plugins. + scim_resource_backends: HashMap>, /// Token backend plugins. token_backends: HashMap>, /// Token restriction backend plugins. @@ -351,6 +359,42 @@ impl PluginManagerApi for PluginManager { )) } + /// Get registered SCIM realm backend. + /// + /// # Parameters + /// * `name` - The name of the backend to retrieve. + /// + /// # Returns + /// A `Result` containing a reference to the `ScimRealmBackend` if found, + /// or a `ScimRealmProviderError`. + #[allow(clippy::borrowed_box)] + fn get_scim_realm_backend>( + &self, + name: S, + ) -> Result<&Arc, ScimRealmProviderError> { + self.scim_realm_backends.get(name.as_ref()).ok_or( + ScimRealmProviderError::UnsupportedDriver(name.as_ref().to_string()), + ) + } + + /// Get registered SCIM resource ownership index backend. + /// + /// # Parameters + /// * `name` - The name of the backend to retrieve. + /// + /// # Returns + /// A `Result` containing a reference to the `ScimResourceBackend` if + /// found, or a `ScimResourceProviderError`. + #[allow(clippy::borrowed_box)] + fn get_scim_resource_backend>( + &self, + name: S, + ) -> Result<&Arc, ScimResourceProviderError> { + self.scim_resource_backends.get(name.as_ref()).ok_or( + ScimResourceProviderError::UnsupportedDriver(name.as_ref().to_string()), + ) + } + /// Get registered token backend. /// /// # Parameters @@ -578,6 +622,34 @@ impl PluginManagerApi for PluginManager { self.role_backends.insert(name.as_ref().to_string(), plugin); } + /// Register SCIM realm backend. + /// + /// # Parameters + /// * `name` - The name of the backend to register. + /// * `plugin` - The backend implementation to register. + fn register_scim_realm_backend>( + &mut self, + name: S, + plugin: Arc, + ) { + self.scim_realm_backends + .insert(name.as_ref().to_string(), plugin); + } + + /// Register SCIM resource ownership index backend. + /// + /// # Parameters + /// * `name` - The name of the backend to register. + /// * `plugin` - The backend implementation to register. + fn register_scim_resource_backend>( + &mut self, + name: S, + plugin: Arc, + ) { + self.scim_resource_backends + .insert(name.as_ref().to_string(), plugin); + } + /// Register token backend. /// /// # Parameters @@ -694,6 +766,8 @@ impl PluginManager { resource_backends: HashMap::new(), revoke_backends: HashMap::new(), role_backends: HashMap::new(), + scim_realm_backends: HashMap::new(), + scim_resource_backends: HashMap::new(), token_backends: HashMap::new(), token_restriction_backends: HashMap::new(), trust_backends: HashMap::new(), @@ -719,6 +793,10 @@ impl PluginManager { "raft", Arc::new(openstack_keystone_api_key_driver_raft::RaftBackend::default()), ); + let scim_raft_backend = + Arc::new(openstack_keystone_scim_driver_raft::RaftBackend::default()); + slf.register_scim_realm_backend("raft", scim_raft_backend.clone()); + slf.register_scim_resource_backend("raft", scim_raft_backend); Ok(slf) } } diff --git a/crates/keystone/src/scim.rs b/crates/keystone/src/scim.rs index 879ed8794..689e33061 100644 --- a/crates/keystone/src/scim.rs +++ b/crates/keystone/src/scim.rs @@ -29,6 +29,10 @@ use serde::Serialize; use openstack_keystone_core::api::api_key_auth::ApiKeyAuth; use openstack_keystone_core::keystone::ServiceState; +pub mod error; +pub mod types; +mod user; + /// Diagnostic response describing the resolved ephemeral security context. #[derive(Serialize)] struct WhoAmI { @@ -49,5 +53,7 @@ async fn whoami(Path(_domain_id): Path, ApiKeyAuth(vsc): ApiKeyAuth) -> /// SCIM ingress sub-router, nested at `/SCIM/v2` in the main binary. pub fn router() -> Router { - Router::new().route("/{domain_id}/whoami", get(whoami)) + Router::new() + .route("/{domain_id}/whoami", get(whoami)) + .nest("/{domain_id}/Users", user::router()) } diff --git a/crates/keystone/src/scim/error.rs b/crates/keystone/src/scim/error.rs new file mode 100644 index 000000000..2877b80de --- /dev/null +++ b/crates/keystone/src/scim/error.rs @@ -0,0 +1,83 @@ +// 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 +//! # SCIM v2 error envelope (ADR 0024 §10) +//! +//! Most SCIM failure modes carry no RFC 7644-specific body: the Realm +//! Activation Gate (§2.B, 403) is enforced entirely inside the +//! [`crate::api::api_key_auth::ScimRealmAuth`] extractor before a handler +//! ever runs, and the Ownership Fencing Algorithm (§3.C, 404) is +//! indistinguishable from "does not exist" by design — both are adequately +//! represented by the generic [`KeystoneApiError`] envelope. Only the +//! collision cases RFC 7644 §3.12 mandates a `scimType` for get their own +//! variant here, shared by Users (this PR) and Groups (a later PR). + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +use openstack_keystone_core::api::KeystoneApiError; + +/// `urn:ietf:params:scim:api:messages:2.0:Error` schema URI. +pub const SCIM_ERROR_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:Error"; + +/// RFC 7644 §3.12 SCIM error response body. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimErrorBody { + pub schemas: Vec, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scim_type: Option, + pub detail: String, +} + +/// SCIM v2 resource-handler error. +#[derive(Debug)] +pub enum ScimApiError { + /// `userName`/`displayName`/`externalId` collision (409, `scimType: + /// "uniqueness"`, ADR 0024 §3.C/§3.D). + Uniqueness(String), + /// Everything else — reuses the generic Keystone error envelope. + Api(KeystoneApiError), +} + +impl IntoResponse for ScimApiError { + fn into_response(self) -> Response { + match self { + Self::Uniqueness(detail) => ( + StatusCode::CONFLICT, + Json(ScimErrorBody { + schemas: vec![SCIM_ERROR_SCHEMA.to_string()], + status: StatusCode::CONFLICT.as_str().to_string(), + scim_type: Some("uniqueness".to_string()), + detail, + }), + ) + .into_response(), + Self::Api(e) => e.into_response(), + } + } +} + +impl From for ScimApiError +where + E: Into, +{ + fn from(value: E) -> Self { + Self::Api(value.into()) + } +} diff --git a/crates/keystone/src/scim/types.rs b/crates/keystone/src/scim/types.rs new file mode 100644 index 000000000..2db33e6b3 --- /dev/null +++ b/crates/keystone/src/scim/types.rs @@ -0,0 +1,244 @@ +// 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 +//! # SCIM v2 `User` wire types (ADR 0024 §4) +//! +//! A pragmatic subset of RFC 7644's `User` resource — no `PATCH`-only +//! attributes, no complex multi-valued attribute constructs beyond a single +//! `emails` list. Attributes without a first-class Keystone `User` field +//! are namespaced under `extra["scim_*"]` per ADR 0024 §4's mapping table. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use openstack_keystone_core::identity::generate_public_id; +use openstack_keystone_core_types::identity::{UserCreate, UserResponse, UserType, UserUpdate}; +use openstack_keystone_core_types::scim::ScimResourceIndex; + +/// `urn:ietf:params:scim:schemas:core:2.0:User` schema URI. +pub const USER_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:User"; +/// `urn:ietf:params:scim:api:messages:2.0:ListResponse` schema URI. +pub const LIST_RESPONSE_SCHEMA: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; + +const EXTRA_GIVEN_NAME: &str = "scim_given_name"; +const EXTRA_FAMILY_NAME: &str = "scim_family_name"; +const EXTRA_DISPLAY_NAME: &str = "scim_display_name"; +const EXTRA_PRIMARY_EMAIL: &str = "scim_primary_email"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimName { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub given_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family_name: Option, +} + +impl ScimName { + fn is_empty(&self) -> bool { + self.given_name.is_none() && self.family_name.is_none() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScimEmail { + pub value: String, + #[serde(default)] + pub primary: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimMeta { + pub resource_type: String, + pub created: String, + pub last_modified: String, +} + +/// `GET`/`POST`/`PUT` response representation of a SCIM `User`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimUser { + pub schemas: Vec, + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + pub user_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub emails: Vec, + pub active: bool, + pub meta: ScimMeta, +} + +impl ScimUser { + /// Build the wire representation from the core Identity record plus its + /// SCIM ownership anchor. + pub fn from_domain(user: &UserResponse, index: &ScimResourceIndex) -> Self { + let given_name = extra_str(&user.extra, EXTRA_GIVEN_NAME); + let family_name = extra_str(&user.extra, EXTRA_FAMILY_NAME); + let name = if given_name.is_some() || family_name.is_some() { + Some(ScimName { + given_name, + family_name, + }) + } else { + None + }; + let emails = extra_str(&user.extra, EXTRA_PRIMARY_EMAIL) + .map(|value| { + vec![ScimEmail { + value, + primary: true, + }] + }) + .unwrap_or_default(); + + Self { + schemas: vec![USER_SCHEMA.to_string()], + id: user.id.clone(), + external_id: index.external_id.clone(), + user_name: user.name.clone(), + name, + display_name: extra_str(&user.extra, EXTRA_DISPLAY_NAME), + emails, + active: user.enabled, + meta: ScimMeta { + resource_type: "User".to_string(), + created: epoch_to_rfc3339(index.created_at), + last_modified: epoch_to_rfc3339(index.updated_at), + }, + } + } +} + +/// `POST`/`PUT` request body — also used for `PUT` full-replace (ADR 0024 +/// PR2 scope: no `PATCH`). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimUserWrite { + #[serde(default)] + pub schemas: Vec, + #[serde(default)] + pub external_id: Option, + pub user_name: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub emails: Vec, + #[serde(default = "default_true")] + pub active: bool, +} + +fn default_true() -> bool { + true +} + +impl ScimUserWrite { + fn extra(&self) -> HashMap { + let mut extra = HashMap::new(); + if let Some(name) = &self.name + && !name.is_empty() + { + if let Some(gn) = &name.given_name { + extra.insert(EXTRA_GIVEN_NAME.to_string(), Value::String(gn.clone())); + } + if let Some(fname) = &name.family_name { + extra.insert(EXTRA_FAMILY_NAME.to_string(), Value::String(fname.clone())); + } + } + if let Some(display_name) = &self.display_name { + extra.insert( + EXTRA_DISPLAY_NAME.to_string(), + Value::String(display_name.clone()), + ); + } + if let Some(email) = self + .emails + .iter() + .find(|e| e.primary) + .or_else(|| self.emails.first()) + { + extra.insert( + EXTRA_PRIMARY_EMAIL.to_string(), + Value::String(email.value.clone()), + ); + } + extra + } + + /// Convert to a core Identity `UserCreate` (ADR 0024 §4 attribute + /// mapping). The user's `id` is derived deterministically from + /// `(domain_id, external_id, "user")` via the same sha256 formula + /// python-keystone uses for federation shadow users — this is what lets + /// a later federated login with the same IdP `sub` claim converge on + /// this same user instead of creating a duplicate (see ADR 0024 dedup + /// fix). The user is created as `NonLocal` (no password, no + /// `local_user` row) since SCIM-provisioned identities are always + /// externally managed. + pub fn to_user_create(&self, domain_id: &str, external_id: &str) -> UserCreate { + UserCreate { + default_project_id: None, + domain_id: domain_id.to_string(), + enabled: Some(self.active), + extra: self.extra(), + federated: None, + id: Some(generate_public_id(domain_id, external_id, "user")), + name: self.user_name.clone(), + options: None, + password: None, + user_type: UserType::NonLocal, + } + } + + /// Convert to a core Identity `UserUpdate` (full-replace `PUT`). + pub fn to_user_update(&self) -> UserUpdate { + UserUpdate { + default_project_id: None, + enabled: Some(self.active), + extra: self.extra(), + federated: None, + name: Some(self.user_name.clone()), + options: None, + password: None, + } + } +} + +/// `GET /Users` list response envelope. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScimListResponse { + pub schemas: Vec, + pub total_results: usize, + pub start_index: usize, + pub items_per_page: usize, + pub resources: Vec, +} + +fn extra_str(extra: &HashMap, key: &str) -> Option { + extra.get(key).and_then(|v| v.as_str()).map(String::from) +} + +fn epoch_to_rfc3339(epoch: i64) -> String { + chrono::DateTime::from_timestamp(epoch, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default() +} diff --git a/crates/keystone/src/scim/user.rs b/crates/keystone/src/scim/user.rs new file mode 100644 index 000000000..349978708 --- /dev/null +++ b/crates/keystone/src/scim/user.rs @@ -0,0 +1,34 @@ +// 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 +//! # `/SCIM/v2/{domain_id}/Users` (ADR 0024 §3, §4, §6.A) + +use axum::{Router, routing::get}; + +use openstack_keystone_core::keystone::ServiceState; + +mod create; +mod delete; +mod list; +mod show; +mod update; + +/// `Users` sub-router, nested at `/{domain_id}/Users` in [`super::router`]. +pub fn router() -> Router { + Router::new() + .route("/", get(list::list).post(create::create)) + .route( + "/{id}", + get(show::show).put(update::update).delete(delete::delete), + ) +} diff --git a/crates/keystone/src/scim/user/create.rs b/crates/keystone/src/scim/user/create.rs new file mode 100644 index 000000000..f162c5c99 --- /dev/null +++ b/crates/keystone/src/scim/user/create.rs @@ -0,0 +1,431 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `POST /SCIM/v2/{domain_id}/Users` (ADR 0024 §3.C, §3.D, §4). + +use axum::{Json, extract::State, http::StatusCode}; +use serde_json::json; + +use openstack_keystone_core::api::KeystoneApiError; +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core::identity::generate_public_id; +use openstack_keystone_core_types::scim::{ + ScimResourceIndexCreate, ScimResourceProviderError, ScimResourceType, +}; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::types::{ScimUser, ScimUserWrite}; + +pub(super) async fn create( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ScimApiError> { + if req.user_name.trim().is_empty() { + return Err(KeystoneApiError::BadRequest("userName is required".to_string()).into()); + } + let Some(external_id) = req.external_id.as_deref().filter(|s| !s.trim().is_empty()) else { + return Err(KeystoneApiError::BadRequest("externalId is required".to_string()).into()); + }; + + let exec = ExecutionContext::from_auth(&state, &ctx); + + state + .policy_enforcer + .enforce( + "identity/scim/user/create", + &ctx, + json!({"user": {"domain_id": realm.domain_id}}), + None, + ) + .await?; + + // ADR 0024 §3.D: domain-wide, case-insensitive `userName` collision + // check — regardless of which realm (or nothing) created the existing + // user. Best-effort pre-flight; the realm-scoped `externalId` claim + // below is the atomic guarantee. + if state + .provider + .get_identity_provider() + .find_user_by_name_ci(&exec, &realm.domain_id, &req.user_name) + .await? + .is_some() + { + return Err(ScimApiError::Uniqueness( + "userName already exists within this domain".to_string(), + )); + } + + // ADR 0024 dedup fix: the user id this create would derive is + // deterministic (`generate_public_id(domain_id, externalId, "user")`), + // the same one a federated JIT login for the same person may already + // have claimed. Check for it explicitly so that case surfaces as a + // clean SCIM 409 instead of an opaque driver error from a primary-key + // collision inside `create_user` below. + let user_id = generate_public_id(&realm.domain_id, external_id, "user"); + if state + .provider + .get_identity_provider() + .get_user(&exec, &user_id) + .await? + .is_some() + { + return Err(ScimApiError::Uniqueness( + "a user already exists for this externalId".to_string(), + )); + } + + let user = state + .provider + .get_identity_provider() + .create_user(&exec, req.to_user_create(&realm.domain_id, external_id)) + .await?; + + let index = match state + .provider + .get_scim_resource_provider() + .create_index( + &exec, + ScimResourceIndexCreate { + domain_id: realm.domain_id.clone(), + provider_id: realm.provider_id.clone(), + resource_type: ScimResourceType::User, + keystone_id: user.id.clone(), + external_id: req.external_id.clone(), + }, + ) + .await + { + Ok(index) => index, + Err(e) => { + // The index write failed (most likely a realm-scoped + // `externalId` collision, ADR 0024 §3.C) after the Identity + // user was already created. Best-effort compensating delete so + // the orphaned user isn't left dangling — the SCIM create as a + // whole still fails either way. + let _ = state + .provider + .get_identity_provider() + .delete_user(&exec, &user.id) + .await; + return Err(match e { + ScimResourceProviderError::Conflict(msg) => ScimApiError::Uniqueness(msg), + other => other.into(), + }); + } + }; + + Ok(( + StatusCode::CREATED, + Json(ScimUser::from_domain(&user, &index)), + )) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::UserResponseBuilder; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::{ScimResourceIndex, ScimResourceProviderError}; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(vec![openstack_keystone_core_types::role::RoleRef { + id: "scim-provisioner-role".to_string(), + name: Some("scim_provisioner".to_string()), + domain_id: None, + }]) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + #[tokio::test] + async fn test_create() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_user_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + identity_mock.expect_create_user().returning(|_, req| { + Ok(UserResponseBuilder::default() + .id("user-1") + .domain_id(req.domain_id.clone()) + .enabled(true) + .name(req.name.clone()) + .build() + .unwrap()) + }); + + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock.expect_create_index().returning(|_, data| { + Ok(ScimResourceIndex { + domain_id: data.domain_id, + provider_id: data.provider_id, + resource_type: data.resource_type, + keystone_id: data.keystone_id, + external_id: data.external_id, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + }) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: Some("ext-1".to_string()), + user_name: "alice".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let (status, Json(body)) = create(domain_scoped_auth("domain-1"), State(state), Json(req)) + .await + .unwrap(); + assert_eq!(status, StatusCode::CREATED); + assert_eq!(body.user_name, "alice"); + assert_eq!(body.id, "user-1"); + assert_eq!(body.external_id.as_deref(), Some("ext-1")); + } + + #[tokio::test] + async fn test_create_rejects_domain_wide_duplicate_username() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_user_by_name_ci() + .returning(|_, _, _| Ok(Some("other-user".to_string()))); + + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: Some("ext-1".to_string()), + user_name: "alice".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(req)).await; + assert!(matches!(result, Err(ScimApiError::Uniqueness(_)))); + } + + #[tokio::test] + async fn test_create_rejects_realm_scoped_external_id_conflict() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_user_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_get_user().returning(|_, _| Ok(None)); + identity_mock.expect_create_user().returning(|_, req| { + Ok(UserResponseBuilder::default() + .id("user-1") + .domain_id(req.domain_id.clone()) + .enabled(true) + .name(req.name.clone()) + .build() + .unwrap()) + }); + identity_mock.expect_delete_user().returning(|_, _| Ok(())); + + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_create_index() + .returning(|_, _| Err(ScimResourceProviderError::Conflict("dup".to_string()))); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: Some("ext-1".to_string()), + user_name: "alice".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(req)).await; + assert!(matches!(result, Err(ScimApiError::Uniqueness(_)))); + } + + #[tokio::test] + async fn test_create_rejects_deterministic_id_collision() { + // A federated JIT login for the same person (matching externalId == + // sub) may already have claimed the deterministic id this create + // would derive. Must surface as a clean SCIM 409, not an opaque + // driver error from a primary-key collision inside `create_user`. + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_find_user_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_get_user().returning(|_, id| { + Ok(Some( + UserResponseBuilder::default() + .id(id.to_string()) + .domain_id("domain-1".to_string()) + .enabled(true) + .name("jit-user".to_string()) + .build() + .unwrap(), + )) + }); + identity_mock.expect_create_user().never(); + + let state = get_mocked_state( + Provider::mocked_builder().mock_identity(identity_mock), + true, + None, + ) + .await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: Some("ext-1".to_string()), + user_name: "alice".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(req)).await; + assert!(matches!(result, Err(ScimApiError::Uniqueness(_)))); + } + + #[tokio::test] + async fn test_create_policy_denied() { + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: Some("ext-1".to_string()), + user_name: "alice".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(req)).await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::Forbidden { .. })) + )); + } + + #[tokio::test] + async fn test_create_rejects_empty_username() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: None, + user_name: " ".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(req)).await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::BadRequest(_))) + )); + } + + #[tokio::test] + async fn test_create_rejects_missing_external_id() { + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; + + let req = ScimUserWrite { + schemas: vec![], + external_id: None, + user_name: "alice".to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + }; + + let result = create(domain_scoped_auth("domain-1"), State(state), Json(req)).await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::BadRequest(_))) + )); + } +} diff --git a/crates/keystone/src/scim/user/delete.rs b/crates/keystone/src/scim/user/delete.rs new file mode 100644 index 000000000..c88efc21b --- /dev/null +++ b/crates/keystone/src/scim/user/delete.rs @@ -0,0 +1,341 @@ +// 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 +//! `DELETE /SCIM/v2/{domain_id}/Users/{id}` — soft-disable only (ADR 0024 +//! §6.A). Never hard-deletes; the janitor purge (a later PR) reclaims +//! tombstoned rows past the retention window. + +use axum::{extract::Path, extract::State, http::StatusCode}; +use chrono::Utc; +use serde_json::json; + +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::events::{Event, EventPayload, Operation}; +use openstack_keystone_core_types::identity::UserUpdate; +use openstack_keystone_core_types::revoke::RevocationEventCreate; +use openstack_keystone_core_types::scim::{ScimResourceIndexUpdate, ScimResourceType}; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::user::show::fetch_owned; + +pub(super) async fn delete( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + Path((_domain_id, id)): Path<(String, String)>, + State(state): State, +) -> Result { + let exec = ExecutionContext::from_auth(&state, &ctx); + let (existing_user, _existing_index) = + fetch_owned(&state, &exec, &realm.domain_id, &realm.provider_id, &id).await?; + + state + .policy_enforcer + .enforce( + "identity/scim/user/delete", + &ctx, + serde_json::Value::Null, + Some(json!({"user": existing_user})), + ) + .await?; + + // §6.A step 1: disable the user (audited as `Operation::Update` by the + // existing `IdentityApi::update_user` path). + state + .provider + .get_identity_provider() + .update_user( + &exec, + &id, + UserUpdate { + enabled: Some(false), + ..Default::default() + }, + ) + .await?; + + // §6.A step 2: stamp `deprovisioned_at` so subsequent reads under this + // realm treat the resource as absent (§3.C). + let now = Utc::now().timestamp(); + state + .provider + .get_scim_resource_provider() + .update_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::User, + &id, + ScimResourceIndexUpdate { + external_id: None, + deprovisioned_at: Some(Some(now)), + }, + ) + .await?; + + // §6.A step 3: revoke live sessions immediately. + state + .provider + .get_revoke_provider() + .create_revocation_event( + &exec, + RevocationEventCreate { + domain_id: Some(realm.domain_id.clone()), + project_id: None, + user_id: Some(id.clone()), + role_id: None, + trust_id: None, + consumer_id: None, + access_token_id: None, + issued_before: Utc::now(), + expires_at: None, + audit_id: None, + audit_chain_id: None, + revoked_at: Utc::now(), + }, + ) + .await?; + + // §6.A step 4: emit a CADF `disable` event. `update_user` above already + // audited an `Update`; this is an additional, ADR-mandated `Disable` + // event enriching the audit trail for the SCIM deprovisioning + // semantics specifically (a judgment call — §9 does not otherwise + // define a dedicated disable-audit call site for Identity users). + state + .event_dispatcher + .emit(Event::new( + Operation::Disable, + EventPayload::User { id: id.clone() }, + )) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::KeystoneApiError; + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::UserResponseBuilder; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::revoke::RevocationEvent; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::revoke::MockRevokeProvider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + #[tokio::test] + async fn test_delete_soft_disables() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| { + Ok(Some(ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: openstack_keystone_core_types::scim::ScimResourceType::User, + keystone_id: id.to_string(), + external_id: None, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + })) + }); + resource_mock + .expect_update_index() + .withf(|_, _, _, _, _, update| matches!(update.deprovisioned_at, Some(Some(_)))) + .returning(|_, _, _, _, id, _| { + Ok(ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: openstack_keystone_core_types::scim::ScimResourceType::User, + keystone_id: id.to_string(), + external_id: None, + version: 1, + deprovisioned_at: Some(999), + created_at: 1, + updated_at: 2, + }) + }); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id| { + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name("alice") + .build() + .unwrap(), + )) + }); + identity_mock.expect_update_user().returning(|_, id, req| { + assert_eq!(req.enabled, Some(false)); + Ok(UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(false) + .name("alice") + .build() + .unwrap()) + }); + + let mut revoke_mock = MockRevokeProvider::default(); + revoke_mock + .expect_create_revocation_event() + .returning(|_, event| { + Ok(RevocationEvent { + domain_id: event.domain_id, + project_id: event.project_id, + user_id: event.user_id, + role_id: event.role_id, + trust_id: event.trust_id, + consumer_id: event.consumer_id, + access_token_id: event.access_token_id, + issued_before: event.issued_before, + expires_at: event.expires_at, + audit_id: event.audit_id, + audit_chain_id: event.audit_chain_id, + revoked_at: event.revoked_at, + }) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock) + .mock_revoke(revoke_mock), + true, + None, + ) + .await; + + let status = delete( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_delete_not_owned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = delete( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } + + #[tokio::test] + async fn test_delete_idempotent_on_repeat() { + // A second DELETE on an already-deprovisioned resource is a 404 + // (RFC 7644 re-delete guidance), not a second successful disable. + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| { + Ok(Some(ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: openstack_keystone_core_types::scim::ScimResourceType::User, + keystone_id: id.to_string(), + external_id: None, + version: 1, + deprovisioned_at: Some(123), + created_at: 1, + updated_at: 1, + })) + }); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = delete( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } +} diff --git a/crates/keystone/src/scim/user/list.rs b/crates/keystone/src/scim/user/list.rs new file mode 100644 index 000000000..9d7a5d065 --- /dev/null +++ b/crates/keystone/src/scim/user/list.rs @@ -0,0 +1,216 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `GET /SCIM/v2/{domain_id}/Users` (ADR 0024 §3, §5.D bare pagination). + +use axum::{Json, extract::Query, extract::State}; +use serde::Deserialize; +use serde_json::json; + +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::ScimResourceType; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::types::{LIST_RESPONSE_SCHEMA, ScimListResponse, ScimUser}; + +#[derive(Debug, Deserialize, Default)] +pub(super) struct ListParams { + #[serde(default)] + start_index: Option, + #[serde(default)] + count: Option, +} + +pub(super) async fn list( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + State(state): State, + Query(params): Query, +) -> Result, ScimApiError> { + state + .policy_enforcer + .enforce( + "identity/scim/user/list", + &ctx, + json!({"user": {"domain_id": realm.domain_id}}), + None, + ) + .await?; + + let exec = ExecutionContext::from_auth(&state, &ctx); + + let indexes = state + .provider + .get_scim_resource_provider() + .list_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::User, + ) + .await?; + + let start_index = params.start_index.unwrap_or(1).max(1); + let count = params.count.unwrap_or(200).min(200); + let total_results = indexes.len(); + + let mut resources = Vec::new(); + for index in indexes + .into_iter() + .filter(|i| i.deprovisioned_at.is_none()) + .skip(start_index.saturating_sub(1)) + .take(count) + { + if let Some(user) = state + .provider + .get_identity_provider() + .get_user(&exec, &index.keystone_id) + .await? + { + resources.push(ScimUser::from_domain(&user, &index)); + } + } + + Ok(Json(ScimListResponse { + schemas: vec![LIST_RESPONSE_SCHEMA.to_string()], + total_results, + start_index, + items_per_page: resources.len(), + resources, + })) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::UserResponseBuilder; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + fn make_index(keystone_id: &str, deprovisioned: bool) -> ScimResourceIndex { + ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::User, + keystone_id: keystone_id.to_string(), + external_id: None, + version: 0, + deprovisioned_at: if deprovisioned { Some(1) } else { None }, + created_at: 1, + updated_at: 1, + } + } + + #[tokio::test] + async fn test_list_filters_deprovisioned() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock.expect_list_index().returning(|_, _, _, _| { + Ok(vec![ + make_index("user-1", false), + make_index("user-2", true), + ]) + }); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id| { + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name("alice") + .build() + .unwrap(), + )) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = list( + domain_scoped_auth("domain-1"), + State(state), + Query(ListParams::default()), + ) + .await + .unwrap(); + assert_eq!(result.resources.len(), 1); + assert_eq!(result.resources[0].id, "user-1"); + // total_results counts everything the realm owns, deprovisioned + // included, matching the raw index count before filtering. + assert_eq!(result.total_results, 2); + } + + #[tokio::test] + async fn test_list_policy_denied() { + let state = get_mocked_state(Provider::mocked_builder(), false, None).await; + + let result = list( + domain_scoped_auth("domain-1"), + State(state), + Query(ListParams::default()), + ) + .await; + assert!(matches!(result, Err(ScimApiError::Api(_)))); + } +} diff --git a/crates/keystone/src/scim/user/show.rs b/crates/keystone/src/scim/user/show.rs new file mode 100644 index 000000000..527d1f40a --- /dev/null +++ b/crates/keystone/src/scim/user/show.rs @@ -0,0 +1,259 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! `GET /SCIM/v2/{domain_id}/Users/{id}` (ADR 0024 §3.C Ownership Fencing). + +use axum::{Json, extract::Path, extract::State}; +use serde_json::json; + +use openstack_keystone_core::api::KeystoneApiError; +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::ScimResourceType; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::types::ScimUser; + +/// Fetch `(UserResponse, ScimResourceIndex)` for `id` under the caller's own +/// realm, enforcing the Ownership Fencing Algorithm (ADR 0024 §3.C): absent +/// index, or a `deprovisioned_at` stamp, is treated as `404 Not Found` — +/// indistinguishable from "does not exist", even if a same-ID resource +/// exists under a different realm or was created manually. +pub(super) async fn fetch_owned( + state: &ServiceState, + exec: &ExecutionContext<'_>, + domain_id: &str, + provider_id: &str, + id: &str, +) -> Result< + ( + openstack_keystone_core_types::identity::UserResponse, + openstack_keystone_core_types::scim::ScimResourceIndex, + ), + KeystoneApiError, +> { + let index = state + .provider + .get_scim_resource_provider() + .get_index(exec, domain_id, provider_id, ScimResourceType::User, id) + .await?; + let Some(index) = index.filter(|i| i.deprovisioned_at.is_none()) else { + return Err(KeystoneApiError::NotFound { + resource: "user".to_string(), + identifier: id.to_string(), + }); + }; + let user = state + .provider + .get_identity_provider() + .get_user(exec, id) + .await?; + let Some(user) = user else { + return Err(KeystoneApiError::NotFound { + resource: "user".to_string(), + identifier: id.to_string(), + }); + }; + Ok((user, index)) +} + +pub(super) async fn show( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + Path((_domain_id, id)): Path<(String, String)>, + State(state): State, +) -> Result, ScimApiError> { + let exec = ExecutionContext::from_auth(&state, &ctx); + let (user, index) = + fetch_owned(&state, &exec, &realm.domain_id, &realm.provider_id, &id).await?; + + state + .policy_enforcer + .enforce( + "identity/scim/user/show", + &ctx, + serde_json::Value::Null, + Some(json!({"user": user})), + ) + .await?; + + Ok(Json(ScimUser::from_domain(&user, &index))) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::UserResponseBuilder; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + #[tokio::test] + async fn test_show() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| { + Ok(Some(ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::User, + keystone_id: id.to_string(), + external_id: None, + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + })) + }); + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id| { + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name("alice") + .build() + .unwrap(), + )) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = show( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + ) + .await + .unwrap(); + assert_eq!(result.id, "user-1"); + } + + #[tokio::test] + async fn test_show_not_owned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = show( + domain_scoped_auth("domain-1"), + Path(( + "domain-1".to_string(), + "user-from-another-realm".to_string(), + )), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } + + #[tokio::test] + async fn test_show_deprovisioned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| { + Ok(Some(ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: ScimResourceType::User, + keystone_id: id.to_string(), + external_id: None, + version: 1, + deprovisioned_at: Some(123), + created_at: 1, + updated_at: 1, + })) + }); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = show( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } +} diff --git a/crates/keystone/src/scim/user/update.rs b/crates/keystone/src/scim/user/update.rs new file mode 100644 index 000000000..951ab7642 --- /dev/null +++ b/crates/keystone/src/scim/user/update.rs @@ -0,0 +1,337 @@ +// 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 +//! `PUT /SCIM/v2/{domain_id}/Users/{id}` — full-replace update (ADR 0024 +//! §3.C, §4; `PATCH` is deferred to a later PR). + +use axum::{Json, extract::Path, extract::State}; +use serde_json::json; + +use openstack_keystone_core::api::api_key_auth::ScimRealmAuth; +use openstack_keystone_core::auth::ExecutionContext; +use openstack_keystone_core_types::scim::{ + ScimResourceIndexUpdate, ScimResourceProviderError, ScimResourceType, +}; + +use crate::keystone::ServiceState; +use crate::scim::error::ScimApiError; +use crate::scim::types::{ScimUser, ScimUserWrite}; +use crate::scim::user::show::fetch_owned; + +pub(super) async fn update( + ScimRealmAuth { ctx, realm }: ScimRealmAuth, + Path((_domain_id, id)): Path<(String, String)>, + State(state): State, + Json(req): Json, +) -> Result, ScimApiError> { + if req.user_name.trim().is_empty() { + return Err(openstack_keystone_core::api::KeystoneApiError::BadRequest( + "userName is required".to_string(), + ) + .into()); + } + + let exec = ExecutionContext::from_auth(&state, &ctx); + let (existing_user, existing_index) = + fetch_owned(&state, &exec, &realm.domain_id, &realm.provider_id, &id).await?; + + state + .policy_enforcer + .enforce( + "identity/scim/user/update", + &ctx, + json!({"user": {"user_name": req.user_name}}), + Some(json!({"user": existing_user})), + ) + .await?; + + // ADR 0024 §3.D: if the `userName` is changing, re-run the domain-wide + // collision check (a no-op rename doesn't need it). The lookup is + // case-insensitive, so exclude the resource's own id — otherwise a + // case-only rename (e.g. `Bob` -> `BOB`) would collide with itself. + if req.user_name != existing_user.name + && let Some(matched_id) = state + .provider + .get_identity_provider() + .find_user_by_name_ci(&exec, &realm.domain_id, &req.user_name) + .await? + && matched_id != id + { + return Err(ScimApiError::Uniqueness( + "userName already exists within this domain".to_string(), + )); + } + + if req.external_id != existing_index.external_id { + match state + .provider + .get_scim_resource_provider() + .update_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::User, + &id, + ScimResourceIndexUpdate { + external_id: Some(req.external_id.clone()), + deprovisioned_at: None, + }, + ) + .await + { + Ok(_) => {} + Err(ScimResourceProviderError::Conflict(msg)) => { + return Err(ScimApiError::Uniqueness(msg)); + } + Err(e) => return Err(e.into()), + } + } + + let user = state + .provider + .get_identity_provider() + .update_user(&exec, &id, req.to_user_update()) + .await?; + + let index = state + .provider + .get_scim_resource_provider() + .get_index( + &exec, + &realm.domain_id, + &realm.provider_id, + ScimResourceType::User, + &id, + ) + .await? + .unwrap_or(existing_index); + + Ok(Json(ScimUser::from_domain(&user, &index))) +} + +#[cfg(test)] +mod tests { + use openstack_keystone_core::api::KeystoneApiError; + use openstack_keystone_core::api::api_key_auth::ScimRealmContext; + use openstack_keystone_core::auth::ValidatedSecurityContext; + use openstack_keystone_core_types::auth::{ + AuthenticationContext, AuthzInfoBuilder, IdentityInfo, PrincipalInfo, ScopeInfo, + SecurityContext, UserIdentityInfoBuilder, + }; + use openstack_keystone_core_types::identity::UserResponseBuilder; + use openstack_keystone_core_types::resource::Domain; + use openstack_keystone_core_types::scim::ScimResourceIndex; + + use super::*; + use crate::api::tests::get_mocked_state; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::scim_resource::MockScimResourceProvider; + + fn domain_scoped_auth(domain_id: &str) -> ScimRealmAuth { + let user = UserIdentityInfoBuilder::default() + .user_id("scim-provisioner") + .build() + .unwrap(); + let authz = AuthzInfoBuilder::default() + .roles(Vec::new()) + .scope(ScopeInfo::Domain(Domain { + id: domain_id.to_string(), + name: String::new(), + description: None, + enabled: true, + extra: Default::default(), + })) + .build() + .unwrap(); + let sc = SecurityContext::test_build() + .authentication_context(AuthenticationContext::Password) + .principal(PrincipalInfo { + identity: IdentityInfo::User(user), + }) + .authorization(authz) + .build(); + ScimRealmAuth { + ctx: ValidatedSecurityContext::test_new(sc), + realm: ScimRealmContext { + domain_id: domain_id.to_string(), + provider_id: "okta-1".to_string(), + }, + } + } + + fn make_index(keystone_id: &str) -> ScimResourceIndex { + ScimResourceIndex { + domain_id: "domain-1".to_string(), + provider_id: "okta-1".to_string(), + resource_type: openstack_keystone_core_types::scim::ScimResourceType::User, + keystone_id: keystone_id.to_string(), + external_id: Some("ext-old".to_string()), + version: 0, + deprovisioned_at: None, + created_at: 1, + updated_at: 1, + } + } + + fn write_req(user_name: &str, external_id: Option<&str>) -> ScimUserWrite { + ScimUserWrite { + schemas: vec![], + external_id: external_id.map(str::to_string), + user_name: user_name.to_string(), + name: None, + display_name: None, + emails: vec![], + active: true, + } + } + + #[tokio::test] + async fn test_update() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + resource_mock + .expect_update_index() + .returning(|_, _, _, _, id, _| Ok(make_index(id))); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id| { + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name("old_name") + .build() + .unwrap(), + )) + }); + identity_mock + .expect_find_user_by_name_ci() + .returning(|_, _, _| Ok(None)); + identity_mock.expect_update_user().returning(|_, id, req| { + Ok(UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name(req.name.clone().unwrap()) + .build() + .unwrap()) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + Json(write_req("new_name", Some("ext-old"))), + ) + .await + .unwrap(); + assert_eq!(result.user_name, "new_name"); + } + + #[tokio::test] + async fn test_update_case_only_rename_does_not_self_conflict() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, id| Ok(Some(make_index(id)))); + resource_mock + .expect_update_index() + .returning(|_, _, _, _, id, _| Ok(make_index(id))); + + let mut identity_mock = MockIdentityProvider::default(); + identity_mock.expect_get_user().returning(|_, id| { + Ok(Some( + UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name("Bob") + .build() + .unwrap(), + )) + }); + // Case-insensitive lookup matches the resource's own id -- must not + // be treated as a collision with another user. + identity_mock + .expect_find_user_by_name_ci() + .returning(|_, _, _| Ok(Some("user-1".to_string()))); + identity_mock.expect_update_user().returning(|_, id, req| { + Ok(UserResponseBuilder::default() + .id(id) + .domain_id("domain-1") + .enabled(true) + .name(req.name.clone().unwrap()) + .build() + .unwrap()) + }); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + Json(write_req("BOB", Some("ext-old"))), + ) + .await + .unwrap(); + assert_eq!(result.user_name, "BOB"); + } + + #[tokio::test] + async fn test_update_not_owned_returns_404() { + let mut resource_mock = MockScimResourceProvider::default(); + resource_mock + .expect_get_index() + .returning(|_, _, _, _, _| Ok(None)); + + let state = get_mocked_state( + Provider::mocked_builder().mock_scim_resource(resource_mock), + true, + None, + ) + .await; + + let result = update( + domain_scoped_auth("domain-1"), + Path(("domain-1".to_string(), "user-1".to_string())), + State(state), + Json(write_req("new_name", None)), + ) + .await; + assert!(matches!( + result, + Err(ScimApiError::Api(KeystoneApiError::NotFound { .. })) + )); + } +} diff --git a/crates/keystone/src/scim_realm.rs b/crates/keystone/src/scim_realm.rs new file mode 100644 index 000000000..8b95cb9f4 --- /dev/null +++ b/crates/keystone/src/scim_realm.rs @@ -0,0 +1,16 @@ +// 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 +//! # SCIM realm provider re-export. + +pub use openstack_keystone_core::scim_realm::*; diff --git a/crates/keystone/src/scim_resource.rs b/crates/keystone/src/scim_resource.rs new file mode 100644 index 000000000..09c891d55 --- /dev/null +++ b/crates/keystone/src/scim_resource.rs @@ -0,0 +1,16 @@ +// 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 +//! # SCIM resource ownership index provider re-export. + +pub use openstack_keystone_core::scim_resource::*; diff --git a/crates/scim-driver-raft/Cargo.toml b/crates/scim-driver-raft/Cargo.toml new file mode 100644 index 000000000..d68d7fc23 --- /dev/null +++ b/crates/scim-driver-raft/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "openstack-keystone-scim-driver-raft" +description = "OpenStack Keystone Raft driver for the SCIM realm provider" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +exclude.workspace = true + +[dependencies] +async-trait.workspace = true +chrono = { workspace = true, default-features = false, features = ["clock"] } +openstack-keystone-core.workspace = true +openstack-keystone-core-types.workspace = true +openstack-keystone-distributed-storage.workspace = true +rmp-serde.workspace = true +tracing.workspace = true + +[dev-dependencies] +openstack-keystone-distributed-storage = { workspace = true, features = ["mock"] } +tokio = { workspace = true, features = ["rt"] } + +[lints] +workspace = true diff --git a/crates/scim-driver-raft/src/lib.rs b/crates/scim-driver-raft/src/lib.rs new file mode 100644 index 000000000..cbbd3d2bc --- /dev/null +++ b/crates/scim-driver-raft/src/lib.rs @@ -0,0 +1,1247 @@ +// 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 +//! # OpenStack Keystone Raft driver for the SCIM realm provider (ADR 0024). +use async_trait::async_trait; +use chrono::Utc; + +use openstack_keystone_core::keystone::ServiceState; +use openstack_keystone_core::scim_realm::backend::ScimRealmBackend; +use openstack_keystone_core::scim_realm::error::ScimRealmProviderError; +use openstack_keystone_core::scim_resource::backend::ScimResourceBackend; +use openstack_keystone_core::scim_resource::error::ScimResourceProviderError; +use openstack_keystone_core_types::scim::*; +use openstack_keystone_distributed_storage::{ + Metadata, StorageApi, StoreDataEnvelope, StoreError, store_command::Mutation, +}; + +/// Raft Database SCIM realm backend. +/// +/// Realms are indexed directly by their `(domain_id, provider_id)` +/// coordinate (ADR 0024 §2.A) — no secondary lookup index is needed since +/// both dimensions are always known before a realm lookup is performed (the +/// Realm Activation Gate, ADR 0024 §2.B, resolves both from the +/// authenticated `ApiClientResource` before consulting this backend). +#[derive(Default)] +pub struct RaftBackend {} + +impl RaftBackend { + /// Primary storage key: `scim_realm:v1::`. + fn get_realm_key_name, P: AsRef>( + &self, + domain_id: D, + provider_id: P, + ) -> String { + format!( + "scim_realm:v1:{}:{}", + domain_id.as_ref(), + provider_id.as_ref() + ) + } + + /// Prefix covering all realms for a domain (used for listing). + fn get_realm_by_domain_prefix>(&self, domain_id: D) -> String { + format!("scim_realm:v1:{}:", domain_id.as_ref()) + } + + /// Primary storage key for a SCIM resource ownership anchor: `scim_ + /// resource:v1::::` (ADR 0024 + /// §3.B). + fn get_resource_key_name, P: AsRef, K: AsRef>( + &self, + domain_id: D, + provider_id: P, + resource_type: ScimResourceType, + keystone_id: K, + ) -> String { + format!( + "scim_resource:v1:{}:{}:{}:{}", + domain_id.as_ref(), + provider_id.as_ref(), + resource_type, + keystone_id.as_ref() + ) + } + + /// Prefix covering all anchors owned by a realm for a resource type + /// (used for listing). + fn get_resource_by_realm_type_prefix, P: AsRef>( + &self, + domain_id: D, + provider_id: P, + resource_type: ScimResourceType, + ) -> String { + format!( + "scim_resource:v1:{}:{}:{}:", + domain_id.as_ref(), + provider_id.as_ref(), + resource_type + ) + } + + /// Realm-scoped `externalId` claim key: `scim_resource:external_id_ + /// claim:v1::::` → value: + /// `keystone_id`. Written with `Mutation::create_if_absent` so a second + /// concurrent create for the same `externalId` within the same realm + /// fails the claim instead of racing (ADR 0024 §3.D's TOCTOU concern, + /// closed here for the realm-scoped dimension of the check — the + /// domain-wide cross-realm `userName` check is a live query against + /// core Identity, which owns a legacy SQL schema this crate does not + /// control, so it remains best-effort; see `find_user_by_name_ci`). + fn get_external_id_claim_key_name, P: AsRef, E: AsRef>( + &self, + domain_id: D, + provider_id: P, + resource_type: ScimResourceType, + external_id: E, + ) -> String { + format!( + "scim_resource:external_id_claim:v1:{}:{}:{}:{}", + domain_id.as_ref(), + provider_id.as_ref(), + resource_type, + external_id.as_ref() + ) + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn create_impl( + &self, + storage: &dyn StorageApi, + data: ScimRealmResourceCreate, + ) -> Result { + let now = Utc::now().timestamp(); + let obj = ScimRealmResource { + domain_id: data.domain_id, + provider_id: data.provider_id, + idp_id: data.idp_id, + display_name: data.display_name, + enabled: true, + created_at: now, + updated_at: now, + }; + let mutations = vec![Mutation::set( + self.get_realm_key_name(&obj.domain_id, &obj.provider_id), + obj.clone(), + Metadata::new(), + None::<&str>, + None, + )?]; + storage.transaction(mutations).await?; + Ok(obj) + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn get_impl( + &self, + storage: &dyn StorageApi, + domain_id: &str, + provider_id: &str, + ) -> Result, StoreError> { + Ok(storage + .get_by_key( + self.get_realm_key_name(domain_id, provider_id).as_bytes(), + None, + ) + .await? + .map(|env| env.try_deserialize::()) + .transpose()? + .map(|x| x.data)) + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn list_impl( + &self, + storage: &dyn StorageApi, + params: &ScimRealmResourceListParameters, + ) -> Result, StoreError> { + let prefix = self.get_realm_by_domain_prefix(¶ms.domain_id); + let mut res: Vec = Vec::new(); + for (_, envelope) in storage.prefix(prefix.as_bytes(), None).await? { + let candidate = envelope.try_deserialize::()?.data; + if let Some(enabled) = params.enabled + && candidate.enabled != enabled + { + continue; + } + res.push(candidate); + } + Ok(res) + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn update_impl( + &self, + storage: &dyn StorageApi, + domain_id: &str, + provider_id: &str, + data: ScimRealmResourceUpdate, + ) -> Result { + let key = self.get_realm_key_name(domain_id, provider_id); + let curr: StoreDataEnvelope = storage + .get_by_key(key.as_bytes(), None) + .await? + .ok_or_else(|| StoreError::IO { + source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"), + })? + .try_deserialize()?; + let new = curr.data.with_update(data, Utc::now().timestamp()); + let new_meta = curr.metadata.new_revision(); + storage + .set_value( + key, + StoreDataEnvelope { + data: rmp_serde::to_vec(&new)?, + metadata: new_meta, + }, + None, + Some(curr.metadata.revision), + ) + .await?; + Ok(new) + } +} + +#[async_trait] +impl ScimRealmBackend for RaftBackend { + async fn create( + &self, + state: &ServiceState, + data: ScimRealmResourceCreate, + ) -> Result { + let raft = state + .storage + .as_deref() + .ok_or(ScimRealmProviderError::RaftNotAvailable)?; + self.create_impl(raft, data) + .await + .map_err(ScimRealmProviderError::raft) + } + + async fn get<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + ) -> Result, ScimRealmProviderError> { + let raft = state + .storage + .as_deref() + .ok_or(ScimRealmProviderError::RaftNotAvailable)?; + self.get_impl(raft, domain_id, provider_id) + .await + .map_err(ScimRealmProviderError::raft) + } + + async fn list( + &self, + state: &ServiceState, + params: &ScimRealmResourceListParameters, + ) -> Result, ScimRealmProviderError> { + let raft = state + .storage + .as_deref() + .ok_or(ScimRealmProviderError::RaftNotAvailable)?; + self.list_impl(raft, params) + .await + .map_err(ScimRealmProviderError::raft) + } + + async fn update<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + data: ScimRealmResourceUpdate, + ) -> Result { + let raft = state + .storage + .as_deref() + .ok_or(ScimRealmProviderError::RaftNotAvailable)?; + match self.update_impl(raft, domain_id, provider_id, data).await { + Ok(obj) => Ok(obj), + Err(e) => { + if e.to_string().contains("NotFound") { + Err(ScimRealmProviderError::NotFound(provider_id.to_string())) + } else { + Err(ScimRealmProviderError::raft(e)) + } + } + } + } +} + +impl RaftBackend { + /// Best-effort release of an `externalId` claim, used to compensate for + /// a primary-index write failing after the claim already committed. + /// Best-effort: this runs on an already-failing path, so a failure here + /// is logged, not propagated -- the caller returns the original error + /// either way, and a leaked claim is recoverable by an operator, whereas + /// masking the original error is not. + async fn release_external_id_claim(&self, storage: &dyn StorageApi, claim_key: Option) { + let Some(key) = claim_key else { return }; + if let Err(e) = storage + .transaction(vec![Mutation::remove(key.clone(), None::<&str>, None)]) + .await + { + tracing::warn!( + claim_key = %key, + error = %e, + "failed to release orphaned externalId claim after primary index write failure" + ); + } + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn create_resource_impl( + &self, + storage: &dyn StorageApi, + data: ScimResourceIndexCreate, + ) -> Result { + let now = Utc::now().timestamp(); + let obj = ScimResourceIndex { + domain_id: data.domain_id, + provider_id: data.provider_id, + resource_type: data.resource_type, + keystone_id: data.keystone_id, + external_id: data.external_id, + version: 0, + deprovisioned_at: None, + created_at: now, + updated_at: now, + }; + + // Claim the realm-scoped `externalId` first (if present) via + // `create_if_absent`, in its own transaction, so a concurrent + // create for the same `externalId` within this realm fails the + // claim rather than racing (ADR 0024 §3.D). + let mut claim_key = None; + if let Some(ref eid) = obj.external_id { + let key = self.get_external_id_claim_key_name( + &obj.domain_id, + &obj.provider_id, + obj.resource_type, + eid, + ); + let response = storage + .transaction(vec![Mutation::create_if_absent( + key.clone(), + obj.keystone_id.clone(), + Metadata::new(), + None::<&str>, + )?]) + .await?; + if !response.violations.is_empty() { + return Err(StoreError::Conflict { + subject: key, + description: "externalId already claimed within this realm".to_string(), + }); + } + claim_key = Some(key); + } + + let primary_write = storage + .transaction(vec![Mutation::set( + self.get_resource_key_name( + &obj.domain_id, + &obj.provider_id, + obj.resource_type, + &obj.keystone_id, + ), + obj.clone(), + Metadata::new(), + None::<&str>, + None, + )?]) + .await; + + // The primary write can fail (transport error, or -- in principle -- + // a violation) after the `externalId` claim above already committed. + // Without releasing it here, the claim would be orphaned: it points + // at an index record that was never written, permanently blocking + // reuse of this `externalId` within the realm (nothing else ever + // deletes a claim except a successful update/delete of the resource + // it's supposed to belong to). + match primary_write { + Ok(response) if response.violations.is_empty() => Ok(obj), + Ok(response) => { + self.release_external_id_claim(storage, claim_key).await; + Err(StoreError::Conflict { + subject: response + .violations + .first() + .map(|v| v.subject.clone()) + .unwrap_or_default(), + description: "failed to persist SCIM resource index".to_string(), + }) + } + Err(e) => { + self.release_external_id_claim(storage, claim_key).await; + Err(e.into()) + } + } + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn get_resource_impl( + &self, + storage: &dyn StorageApi, + domain_id: &str, + provider_id: &str, + resource_type: ScimResourceType, + keystone_id: &str, + ) -> Result, StoreError> { + Ok(storage + .get_by_key( + self.get_resource_key_name(domain_id, provider_id, resource_type, keystone_id) + .as_bytes(), + None, + ) + .await? + .map(|env| env.try_deserialize::()) + .transpose()? + .map(|x| x.data)) + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn get_resource_by_external_id_impl( + &self, + storage: &dyn StorageApi, + domain_id: &str, + provider_id: &str, + resource_type: ScimResourceType, + external_id: &str, + ) -> Result, StoreError> { + let claim_key = + self.get_external_id_claim_key_name(domain_id, provider_id, resource_type, external_id); + let Some(env) = storage.get_by_key(claim_key.as_bytes(), None).await? else { + return Ok(None); + }; + let keystone_id = env.try_deserialize::()?.data; + self.get_resource_impl(storage, domain_id, provider_id, resource_type, &keystone_id) + .await + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn list_resource_impl( + &self, + storage: &dyn StorageApi, + domain_id: &str, + provider_id: &str, + resource_type: ScimResourceType, + ) -> Result, StoreError> { + let prefix = self.get_resource_by_realm_type_prefix(domain_id, provider_id, resource_type); + let mut res: Vec = Vec::new(); + for (_, envelope) in storage.prefix(prefix.as_bytes(), None).await? { + res.push(envelope.try_deserialize::()?.data); + } + Ok(res) + } + + #[cfg_attr(not(test), allow(dead_code))] + async fn update_resource_impl( + &self, + storage: &dyn StorageApi, + domain_id: &str, + provider_id: &str, + resource_type: ScimResourceType, + keystone_id: &str, + data: ScimResourceIndexUpdate, + ) -> Result { + let key = self.get_resource_key_name(domain_id, provider_id, resource_type, keystone_id); + let curr: StoreDataEnvelope = storage + .get_by_key(key.as_bytes(), None) + .await? + .ok_or_else(|| StoreError::IO { + source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"), + })? + .try_deserialize()?; + + // Re-claim `externalId` before persisting, if it changed: release + // the old claim (if any) and atomically claim the new one so two + // concurrent updates can't both succeed onto the same new + // `externalId` within this realm. + if let Some(ref new_eid) = data.external_id + && new_eid != &curr.data.external_id + { + if let Some(ref old_eid) = curr.data.external_id { + let old_claim_key = self.get_external_id_claim_key_name( + domain_id, + provider_id, + resource_type, + old_eid, + ); + storage + .transaction(vec![Mutation::remove(old_claim_key, None::<&str>, None)]) + .await?; + } + if let Some(eid) = new_eid { + let claim_key = + self.get_external_id_claim_key_name(domain_id, provider_id, resource_type, eid); + let response = storage + .transaction(vec![Mutation::create_if_absent( + claim_key.clone(), + keystone_id.to_string(), + Metadata::new(), + None::<&str>, + )?]) + .await?; + if !response.violations.is_empty() { + return Err(StoreError::Conflict { + subject: claim_key, + description: "externalId already claimed within this realm".to_string(), + }); + } + } + } + + let new = curr.data.with_update(data, Utc::now().timestamp()); + let new_meta = curr.metadata.new_revision(); + storage + .set_value( + key, + StoreDataEnvelope { + data: rmp_serde::to_vec(&new)?, + metadata: new_meta, + }, + None, + Some(curr.metadata.revision), + ) + .await?; + Ok(new) + } +} + +#[async_trait] +impl ScimResourceBackend for RaftBackend { + async fn create( + &self, + state: &ServiceState, + data: ScimResourceIndexCreate, + ) -> Result { + let raft = state + .storage + .as_deref() + .ok_or(ScimResourceProviderError::RaftNotAvailable)?; + match self.create_resource_impl(raft, data).await { + Ok(obj) => Ok(obj), + Err(StoreError::Conflict { description, .. }) => { + Err(ScimResourceProviderError::Conflict(description)) + } + Err(e) => Err(ScimResourceProviderError::raft(e)), + } + } + + async fn get<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + ) -> Result, ScimResourceProviderError> { + let raft = state + .storage + .as_deref() + .ok_or(ScimResourceProviderError::RaftNotAvailable)?; + self.get_resource_impl(raft, domain_id, provider_id, resource_type, keystone_id) + .await + .map_err(ScimResourceProviderError::raft) + } + + async fn get_by_external_id<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + external_id: &'a str, + ) -> Result, ScimResourceProviderError> { + let raft = state + .storage + .as_deref() + .ok_or(ScimResourceProviderError::RaftNotAvailable)?; + self.get_resource_by_external_id_impl( + raft, + domain_id, + provider_id, + resource_type, + external_id, + ) + .await + .map_err(ScimResourceProviderError::raft) + } + + async fn list<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + ) -> Result, ScimResourceProviderError> { + let raft = state + .storage + .as_deref() + .ok_or(ScimResourceProviderError::RaftNotAvailable)?; + self.list_resource_impl(raft, domain_id, provider_id, resource_type) + .await + .map_err(ScimResourceProviderError::raft) + } + + async fn update<'a>( + &self, + state: &ServiceState, + domain_id: &'a str, + provider_id: &'a str, + resource_type: ScimResourceType, + keystone_id: &'a str, + data: ScimResourceIndexUpdate, + ) -> Result { + let raft = state + .storage + .as_deref() + .ok_or(ScimResourceProviderError::RaftNotAvailable)?; + match self + .update_resource_impl( + raft, + domain_id, + provider_id, + resource_type, + keystone_id, + data, + ) + .await + { + Ok(obj) => Ok(obj), + Err(StoreError::Conflict { description, .. }) => { + Err(ScimResourceProviderError::Conflict(description)) + } + Err(e) => { + if e.to_string().contains("NotFound") { + Err(ScimResourceProviderError::NotFound(keystone_id.to_string())) + } else { + Err(ScimResourceProviderError::raft(e)) + } + } + } + } +} + +/// Linkage anchor — see ADR-0018. Referenced by the `keystone` crate's +/// `build.rs`-generated `_ANCHORS` static so the linker extracts `.rlib` +/// members, keeping `inventory::submit!` sections visible at runtime. +#[allow(dead_code)] +pub fn anchor() {} + +#[cfg(test)] +mod tests { + use super::*; + use openstack_keystone_distributed_storage::ApiStoreError; + use openstack_keystone_distributed_storage::mock::MockStorage; + + fn make_create(domain_id: &str, provider_id: &str) -> ScimRealmResourceCreate { + ScimRealmResourceCreate { + domain_id: domain_id.to_string(), + provider_id: provider_id.to_string(), + idp_id: "idp-1".to_string(), + display_name: "Okta - Employees".to_string(), + } + } + + #[tokio::test] + async fn test_create_and_get() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + let created = backend + .create_impl(&storage, make_create("domain-1", "provider-1")) + .await + .unwrap(); + assert!(created.enabled); + + let fetched = backend + .get_impl(&storage, "domain-1", "provider-1") + .await + .unwrap(); + assert_eq!(fetched.unwrap().display_name, "Okta - Employees"); + } + + #[tokio::test] + async fn test_get_missing_returns_none() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + let fetched = backend + .get_impl(&storage, "domain-1", "nonexistent") + .await + .unwrap(); + assert!(fetched.is_none()); + } + + #[tokio::test] + async fn test_list_by_domain_and_enabled_filter() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_impl(&storage, make_create("domain-1", "provider-1")) + .await + .unwrap(); + backend + .create_impl(&storage, make_create("domain-1", "provider-2")) + .await + .unwrap(); + backend + .create_impl(&storage, make_create("domain-2", "provider-3")) + .await + .unwrap(); + + let params = ScimRealmResourceListParameters { + domain_id: "domain-1".to_string(), + enabled: None, + }; + let listed = backend.list_impl(&storage, ¶ms).await.unwrap(); + assert_eq!(listed.len(), 2); + + backend + .update_impl( + &storage, + "domain-1", + "provider-1", + ScimRealmResourceUpdate { + idp_id: None, + display_name: None, + enabled: Some(false), + }, + ) + .await + .unwrap(); + + let params_enabled = ScimRealmResourceListParameters { + domain_id: "domain-1".to_string(), + enabled: Some(true), + }; + let listed_enabled = backend.list_impl(&storage, ¶ms_enabled).await.unwrap(); + assert_eq!(listed_enabled.len(), 1); + assert_eq!(listed_enabled[0].provider_id, "provider-2"); + } + + #[tokio::test] + async fn test_update_disables_realm() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_impl(&storage, make_create("domain-1", "provider-1")) + .await + .unwrap(); + + let updated = backend + .update_impl( + &storage, + "domain-1", + "provider-1", + ScimRealmResourceUpdate { + idp_id: None, + display_name: None, + enabled: Some(false), + }, + ) + .await + .unwrap(); + assert!(!updated.enabled); + + let fetched = backend + .get_impl(&storage, "domain-1", "provider-1") + .await + .unwrap() + .unwrap(); + assert!(!fetched.enabled); + } + + #[tokio::test] + async fn test_update_missing_realm_errors() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + let result = backend + .update_impl( + &storage, + "domain-1", + "nonexistent", + ScimRealmResourceUpdate { + idp_id: None, + display_name: None, + enabled: Some(false), + }, + ) + .await; + assert!(result.is_err()); + } + + // ----------------------------------------------------------------- + // ScimResourceIndex (ADR 0024 §3.A) tests + // ----------------------------------------------------------------- + + fn make_resource_create( + domain_id: &str, + provider_id: &str, + keystone_id: &str, + external_id: Option<&str>, + ) -> ScimResourceIndexCreate { + ScimResourceIndexCreate { + domain_id: domain_id.to_string(), + provider_id: provider_id.to_string(), + resource_type: ScimResourceType::User, + keystone_id: keystone_id.to_string(), + external_id: external_id.map(|s| s.to_string()), + } + } + + /// `StorageApi` wrapper that fails the `nth` call to `transaction` + /// (1-indexed) with an IO error, delegating everything else to the + /// inner `MockStorage`. Used to simulate the primary-index write failing + /// *after* the `externalId` claim's own transaction already committed. + struct FailingNthTransactionStorage { + inner: MockStorage, + nth: usize, + calls: std::sync::atomic::AtomicUsize, + } + + #[async_trait::async_trait] + impl StorageApi for FailingNthTransactionStorage { + async fn contains_key( + &self, + key: &[u8], + keyspace: Option<&str>, + ) -> Result { + self.inner.contains_key(key, keyspace).await + } + + async fn get_by_key( + &self, + key: &[u8], + keyspace: Option<&str>, + ) -> Result>>, ApiStoreError> { + self.inner.get_by_key(key, keyspace).await + } + + async fn prefix( + &self, + prefix: &[u8], + keyspace: Option<&str>, + ) -> Result>)>, ApiStoreError> { + self.inner.prefix(prefix, keyspace).await + } + + async fn prefix_index(&self, prefix: &[u8]) -> Result, ApiStoreError> { + self.inner.prefix_index(prefix).await + } + + async fn remove( + &self, + key: String, + keyspace: Option, + ) -> Result { + self.inner.remove(key, keyspace).await + } + + async fn remove_index( + &self, + key: String, + ) -> Result { + self.inner.remove_index(key).await + } + + async fn set_value( + &self, + key: String, + value: StoreDataEnvelope>, + keyspace: Option, + expected_revision: Option, + ) -> Result { + self.inner + .set_value(key, value, keyspace, expected_revision) + .await + } + + async fn set_index_key( + &self, + key: String, + ) -> Result { + self.inner.set_index_key(key).await + } + + async fn transaction( + &self, + mutations: Vec, + ) -> Result { + let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + if call == self.nth { + return Err(ApiStoreError::other(std::io::Error::other( + "simulated primary write failure", + ))); + } + self.inner.transaction(mutations).await + } + + async fn is_initialized(&self) -> Result { + self.inner.is_initialized().await + } + + async fn initialize( + &self, + nodes: std::collections::HashMap, + ) -> Result<(), ApiStoreError> { + self.inner.initialize(nodes).await + } + + async fn current_leader(&self) -> Option { + self.inner.current_leader().await + } + + async fn keyspace_exists(&self, keyspace: &str) -> Result { + self.inner.keyspace_exists(keyspace).await + } + + async fn drop_keyspace(&self, keyspace: &str) -> Result<(), ApiStoreError> { + self.inner.drop_keyspace(keyspace).await + } + + async fn node_id(&self) -> u64 { + self.inner.node_id().await + } + } + + #[tokio::test] + async fn test_create_resource_releases_claim_when_primary_write_fails() { + let backend = RaftBackend::default(); + // 1st `transaction` call claims the externalId; 2nd is the primary + // index write, which this storage fails. + let storage = FailingNthTransactionStorage { + inner: MockStorage::default(), + nth: 2, + calls: std::sync::atomic::AtomicUsize::new(0), + }; + + let result = backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", Some("ext-1")), + ) + .await; + assert!(result.is_err()); + + // The claim must have been released: a retry with the same + // `externalId` (against a storage that doesn't fail this time) + // succeeds instead of permanently conflicting. + let retry = backend + .create_resource_impl( + &storage.inner, + make_resource_create("domain-1", "provider-1", "user-2", Some("ext-1")), + ) + .await; + assert!(retry.is_ok()); + } + + #[tokio::test] + async fn test_create_and_get_resource_index() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + let created = backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", Some("ext-1")), + ) + .await + .unwrap(); + assert_eq!(created.version, 0); + assert!(created.deprovisioned_at.is_none()); + + let fetched = backend + .get_resource_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "user-1", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(fetched.keystone_id, "user-1"); + assert_eq!(fetched.external_id.as_deref(), Some("ext-1")); + } + + #[tokio::test] + async fn test_get_resource_index_missing_returns_none() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + let fetched = backend + .get_resource_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "nonexistent", + ) + .await + .unwrap(); + assert!(fetched.is_none()); + } + + #[tokio::test] + async fn test_get_resource_by_external_id() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", Some("ext-1")), + ) + .await + .unwrap(); + + let fetched = backend + .get_resource_by_external_id_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "ext-1", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(fetched.keystone_id, "user-1"); + + let missing = backend + .get_resource_by_external_id_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "nonexistent", + ) + .await + .unwrap(); + assert!(missing.is_none()); + } + + #[tokio::test] + async fn test_create_resource_duplicate_external_id_within_realm_conflicts() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", Some("ext-1")), + ) + .await + .unwrap(); + + let result = backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-2", Some("ext-1")), + ) + .await; + assert!(matches!(result, Err(StoreError::Conflict { .. }))); + + // The second create's primary record must not have been persisted: + // the claim transaction runs first and its violation short-circuits + // `create_resource_impl` before the primary write is even attempted. + let fetched = backend + .get_resource_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "user-2", + ) + .await + .unwrap(); + assert!(fetched.is_none()); + } + + #[tokio::test] + async fn test_create_resource_same_external_id_different_realm_allowed() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", Some("ext-1")), + ) + .await + .unwrap(); + + let created = backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-2", "user-2", Some("ext-1")), + ) + .await + .unwrap(); + assert_eq!(created.keystone_id, "user-2"); + } + + #[tokio::test] + async fn test_list_resource_index_scoped_to_realm_and_type() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", None), + ) + .await + .unwrap(); + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-2", None), + ) + .await + .unwrap(); + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-2", "user-3", None), + ) + .await + .unwrap(); + + let listed = backend + .list_resource_impl(&storage, "domain-1", "provider-1", ScimResourceType::User) + .await + .unwrap(); + assert_eq!(listed.len(), 2); + + let listed_group = backend + .list_resource_impl(&storage, "domain-1", "provider-1", ScimResourceType::Group) + .await + .unwrap(); + assert!(listed_group.is_empty()); + } + + #[tokio::test] + async fn test_update_resource_soft_disable() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", None), + ) + .await + .unwrap(); + + let updated = backend + .update_resource_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "user-1", + ScimResourceIndexUpdate { + external_id: None, + deprovisioned_at: Some(Some(12345)), + }, + ) + .await + .unwrap(); + assert_eq!(updated.deprovisioned_at, Some(12345)); + assert_eq!(updated.version, 1); + } + + #[tokio::test] + async fn test_update_resource_changes_external_id_claim() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-1", Some("ext-old")), + ) + .await + .unwrap(); + + backend + .update_resource_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "user-1", + ScimResourceIndexUpdate { + external_id: Some(Some("ext-new".to_string())), + deprovisioned_at: None, + }, + ) + .await + .unwrap(); + + // Old claim released: a new resource can now claim it. + backend + .create_resource_impl( + &storage, + make_resource_create("domain-1", "provider-1", "user-2", Some("ext-old")), + ) + .await + .unwrap(); + + // New claim in effect: resolves back to user-1. + let fetched = backend + .get_resource_by_external_id_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "ext-new", + ) + .await + .unwrap() + .unwrap(); + assert_eq!(fetched.keystone_id, "user-1"); + } + + #[tokio::test] + async fn test_update_resource_missing_errors() { + let backend = RaftBackend::default(); + let storage = MockStorage::default(); + + let result = backend + .update_resource_impl( + &storage, + "domain-1", + "provider-1", + ScimResourceType::User, + "nonexistent", + ScimResourceIndexUpdate { + external_id: None, + deprovisioned_at: Some(Some(1)), + }, + ) + .await; + assert!(result.is_err()); + } +} diff --git a/doc/src/adr/0020-mapping-engine.md b/doc/src/adr/0020-mapping-engine.md index a4dd0f88b..6de99db5c 100644 --- a/doc/src/adr/0020-mapping-engine.md +++ b/doc/src/adr/0020-mapping-engine.md @@ -1051,6 +1051,18 @@ single database transaction: | `AllowedDomainsRequired(mode)` | 422 | `"detail.allowed_domains_required"` | `ClaimsOnly`/`ClaimsOrMapping` ruleset submitted without non-empty `allowed_domains` | | `InterpolatedValueTooLong(msg)` | 400 | `"detail.interpolated_value_too_long"` | Template interpolation exceeds 256 char limit (rejects blank records) | | `RulesetVersionMismatch` | 401 | `"detail.ruleset_version_mismatch"` | Token shadow version differs from live ruleset (version numbers omitted from error response) | +| `RoleNotFound(id)` | 422 | `"detail.role_not_found"` | A rule's `Authorization::{Project,Domain,System}.roles` references a `RoleRef` whose `id` does not match any existing `Role` | + +`RoleNotFound` closes a gap distinct from the other write-time checks above: +a typo'd or invented role reference previously produced a rule whose +authorization could never resolve to a real `Role`, and failed silently +rather than at creation time (see ADR 0024 §8, where exactly this class of +bug — invented `SystemAdmin`/`DomainManager` role literals with no backing +`Role` — was found and fixed in the SCIM policies). Rule create/update now +resolves every referenced `RoleRef.id` against the `Role` store +(`RoleApi::get_role`) and rejects the ruleset with `422` if it does not +exist. `RoleRef.id` is mandatory (unlike `name`, which is optional), so this +is the field the check keys on. --- diff --git a/doc/src/adr/0021-api-key-scim.md b/doc/src/adr/0021-api-key-scim.md index 16b238b39..79df5b25c 100644 --- a/doc/src/adr/0021-api-key-scim.md +++ b/doc/src/adr/0021-api-key-scim.md @@ -254,8 +254,19 @@ requiring the pre-existing `manager` role scoped to the key's own domain (this codebase's realization of `DomainManager`, used identically by `identity.user.*` and `identity.mapping.ruleset.*`), or `admin`/`is_admin` (`SystemAdmin`). Unlike `identity.user.list`, there is no `reader` carve-out -on `identity:api_key:list` — all actions sit at the same privilege bar. This -does not by itself constitute the formal ADR 0002 ratification called for +on `identity:api_key:list` — all actions sit at the same privilege bar. + +ADR 0024's SCIM resource-CRUD policies (`identity/scim/user/*`) reuse this same +`manager`/`admin` string convention, but resolve it through a materially +different path: those requests are authenticated by an API key, which carries +no Role/RoleAssignment at all, so the `manager`/`admin`/`scim_provisioner` +strings they check are produced entirely by the realm's own `MappingRuleSet` +output (§3 Step 4 above), never by a `RoleAssignment` row. The admin CRUD +surface for API keys/realms documented in this section is the opposite case — +invoked by a Fernet-authenticated human operator, where `manager`/`admin` can +be a real RBAC grant. + +This does not by itself constitute the formal ADR 0002 ratification called for above (see §8). ### B. CRUD Endpoints diff --git a/doc/src/adr/0024-scim-v2-provisioning.md b/doc/src/adr/0024-scim-v2-provisioning.md index 0294c568a..f9884cba2 100644 --- a/doc/src/adr/0024-scim-v2-provisioning.md +++ b/doc/src/adr/0024-scim-v2-provisioning.md @@ -456,11 +456,14 @@ accounts — merely by guessing or enumerating a Keystone user ID. ## 8. Authorization & OPA Policies -Realm CRUD (`POST/GET/PATCH /v4/scim-realms`) reuses the `DomainManager` role -established in ADR 0021 §5.A (never `DomainAdmin`), under new policies named per -the slash-separated convention actually used by every implemented policy call -site in `crates/keystone/src/api/v4/**` and the corresponding `.rego` packages -(e.g. `identity/user/create`) — **not** the colon-separated form +Realm CRUD (`POST/GET/PATCH /v4/scim-realms`) is invoked by a +Fernet-authenticated human operator, not a SCIM API key, so its authorization +reuses the actual, pre-existing `manager` role (this codebase's realization of +"DomainManager" — see ADR 0021 §5.A) or `admin`/`is_admin` (never +`DomainAdmin`), under new policies named per the slash-separated convention +actually used by every implemented policy call site in +`crates/keystone/src/api/v4/**` and the corresponding `.rego` packages (e.g. +`identity/user/create`) — **not** the colon-separated form `identity:api_key:create` that ADR 0021 §5.A used only in prose and was never implemented: @@ -468,10 +471,17 @@ implemented: `identity/scim_realm/show` / `identity/scim_realm/disable` SCIM resource CRUD authorization is enforced exactly like any other v4 endpoint -per ADR 0002: the ephemeral `ValidatedSecurityContext`'s UME-resolved roles (ADR -0020/0021 — an operator maps a role such as `ScimProvisioner` onto the realm's -`provider_id` in its `MappingRuleSet`, the same way any other API-key -authorization is granted) are evaluated against: +per ADR 0002, but with one important distinction from realm CRUD above: SCIM +resource requests are authenticated exclusively via API-key ingress (ADR +0021), and `ApiClientResource` carries **no Role field and no +RoleAssignment at all**. The `roles` evaluated by these policies are therefore +never RBAC-assigned — they are entirely the `Authorization::Domain{roles}` +value produced by evaluating the realm's own `MappingRuleSet` (ADR 0020 UME / +ADR 0021 §3 Step 4 `hydrate_ephemeral_context`) at request time. An operator +grants access by authoring a mapping rule whose output includes the role +string `manager`, `admin`, or `scim_provisioner` onto the realm's +`provider_id` — not by assigning a Keystone `Role` to anything, since no such +assignment surface exists for API keys. These policies are evaluated against: - `identity/scim/user/create` / `identity/scim/user/list` / `identity/scim/user/show` / `identity/scim/user/update` / @@ -484,6 +494,16 @@ authorization is granted) are evaluated against: slash convention in a future revision of that ADR; this ADR does not attempt to fix 0021 retroactively, only avoids repeating its naming inconsistency. +**Role-existence enforcement (ADR 0020 §7.3):** the `manager`/`admin`/ +`scim_provisioner` role strings above are only meaningful if a `Role` with +that exact name actually exists — the naming-drift bug this ADR originally +shipped with (invented `SystemAdmin`/`DomainManager` literals with no +backing `Role`, silently producing an unreachable authorization) is now +caught structurally: mapping rule create/update rejects any `RoleRef` +whose `id` doesn't resolve against the `Role` store with `422 +Unprocessable Entity` (`MappingProviderError::RoleNotFound`), rather than +relying on this ADR's prose staying in sync with the rego by hand. + The §3.C ownership-fencing check happens **before** OPA evaluation and is not a substitute for it — a realm's own credential may still lack a role authorizing a given operation even against its own resources. diff --git a/policy/identity.rego b/policy/identity.rego index bfdeb9d67..1cb73d9bc 100644 --- a/policy/identity.rego +++ b/policy/identity.rego @@ -97,6 +97,10 @@ any_domain_id := input.target.domain.id if { input.target.domain.id } +any_domain_id := input.target.scim_realm.domain_id if { + input.target.scim_realm.domain_id +} + any_domain_id := input.existing.instance.domain_id if { input.existing.instance.domain_id } @@ -129,6 +133,22 @@ any_domain_id := input.existing.domain.id if { input.existing.domain.id } +any_domain_id := input.existing.scim_realm.domain_id if { + input.existing.scim_realm.domain_id +} + +any_domain_id := input.target.scim_realm.domain_id if { + input.target.scim_realm.domain_id +} + +any_domain_id := input.existing.user.domain_id if { + input.existing.user.domain_id +} + +any_domain_id := input.target.user.domain_id if { + input.target.user.domain_id +} + project_domain_matches_domain_scope if { input.target.project.domain_id != null input.target.project.domain_id = input.credentials.domain_id diff --git a/policy/identity/scim/user/create.rego b/policy/identity/scim/user/create.rego new file mode 100644 index 000000000..b0a42954b --- /dev/null +++ b/policy/identity/scim/user/create.rego @@ -0,0 +1,53 @@ +# METADATA +# description: Policy for provisioning a SCIM user (ADR 0024 §3, §8) +package identity.scim.user.create + +import data.identity + +# Create a new SCIM-provisioned user. +# +# The `input.target.user` carries at least: +# domain_id: string Domain the realm is scoped to. +# +# The `input.existing` is null. +# +# The Realm Activation Gate (ADR 0024 §2.B) and the domain-only scope +# restriction (§2.C) are already enforced by the `ScimRealmAuth` extractor +# before this policy runs. This check additionally requires the realm's +# ephemeral identity to carry the `scim_provisioner` role. Since SCIM +# resource CRUD is invoked exclusively via API-key ingress (ADR 0021), +# `manager`/`admin`/`scim_provisioner` here are never backed by a real +# RoleAssignment -- API keys carry no Role at all. Each is simply a string +# an operator configures the realm's own MappingRuleSet (ADR 0020 UME) to +# emit as `Authorization::Domain{roles}` at request time (ADR 0024 §8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "provisioning a SCIM user in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "provisioning a SCIM user requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/user/create_test.rego b/policy/identity/scim/user/create_test.rego new file mode 100644 index 000000000..a0af81869 --- /dev/null +++ b/policy/identity/scim/user/create_test.rego @@ -0,0 +1,17 @@ +package test_scim_user_create + +import data.identity.scim.user.create + +test_allowed if { + create.allow with input as {"credentials": {"roles": ["admin"]}} + create.allow with input as {"credentials": {"roles": [], "is_admin": true}} + create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}}} + create.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}}} +} + +test_forbidden if { + not create.allow with input as {"credentials": {"roles": []}} + not create.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}}} + not create.allow with input as {"credentials": {"roles": ["scim_provisioner"]}, "target": {"user": {"domain_id": "foo"}}} + not create.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/user/delete.rego b/policy/identity/scim/user/delete.rego new file mode 100644 index 000000000..f43ea7419 --- /dev/null +++ b/policy/identity/scim/user/delete.rego @@ -0,0 +1,44 @@ +# METADATA +# description: Policy for soft-disabling a SCIM-provisioned user (ADR 0024 §3, §6.A, §8) +package identity.scim.user.delete + +import data.identity + +# `DELETE` (soft-disable) a SCIM-provisioned user. +# +# The `input.existing.user` is the stored `UserResponse`. +# +# The Ownership Fencing Algorithm (ADR 0024 §3.C) has already run in the +# handler before this policy is evaluated — this is a second, independent +# authorization check, not a substitute for it (§8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "disabling a SCIM user in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "disabling a SCIM user requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/user/delete_test.rego b/policy/identity/scim/user/delete_test.rego new file mode 100644 index 000000000..3e74cb27c --- /dev/null +++ b/policy/identity/scim/user/delete_test.rego @@ -0,0 +1,15 @@ +package test_scim_user_delete + +import data.identity.scim.user.delete + +test_allowed if { + delete.allow with input as {"credentials": {"roles": ["admin"]}} + delete.allow with input as {"credentials": {"roles": [], "is_admin": true}} + delete.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo"}}} +} + +test_forbidden if { + not delete.allow with input as {"credentials": {"roles": []}} + not delete.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo1"}}} + not delete.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/user/list.rego b/policy/identity/scim/user/list.rego new file mode 100644 index 000000000..9eb469ea7 --- /dev/null +++ b/policy/identity/scim/user/list.rego @@ -0,0 +1,43 @@ +# METADATA +# description: Policy for listing SCIM-provisioned users (ADR 0024 §3, §8) +package identity.scim.user.list + +import data.identity + +# List SCIM-provisioned users owned by the realm. +# +# The `input.target.user` carries at least: +# domain_id: string Domain the realm is scoped to. +# +# The `input.existing` is null. +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "listing SCIM users in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "listing SCIM users requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/user/list_test.rego b/policy/identity/scim/user/list_test.rego new file mode 100644 index 000000000..52ae27aca --- /dev/null +++ b/policy/identity/scim/user/list_test.rego @@ -0,0 +1,15 @@ +package test_scim_user_list + +import data.identity.scim.user.list + +test_allowed if { + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": [], "is_admin": true}} + list.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/user/show.rego b/policy/identity/scim/user/show.rego new file mode 100644 index 000000000..c8d0daae0 --- /dev/null +++ b/policy/identity/scim/user/show.rego @@ -0,0 +1,44 @@ +# METADATA +# description: Policy for showing a SCIM-provisioned user (ADR 0024 §3, §8) +package identity.scim.user.show + +import data.identity + +# Show a single SCIM-provisioned user. +# +# The `input.existing.user` is the stored `UserResponse`. +# +# The Ownership Fencing Algorithm (ADR 0024 §3.C) has already run in the +# handler before this policy is evaluated — this is a second, independent +# authorization check, not a substitute for it (§8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "showing a SCIM user in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "showing a SCIM user requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/user/show_test.rego b/policy/identity/scim/user/show_test.rego new file mode 100644 index 000000000..4a32cb1d0 --- /dev/null +++ b/policy/identity/scim/user/show_test.rego @@ -0,0 +1,15 @@ +package test_scim_user_show + +import data.identity.scim.user.show + +test_allowed if { + show.allow with input as {"credentials": {"roles": ["admin"]}} + show.allow with input as {"credentials": {"roles": [], "is_admin": true}} + show.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo"}}} +} + +test_forbidden if { + not show.allow with input as {"credentials": {"roles": []}} + not show.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo1"}}} + not show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim/user/update.rego b/policy/identity/scim/user/update.rego new file mode 100644 index 000000000..7367dcdca --- /dev/null +++ b/policy/identity/scim/user/update.rego @@ -0,0 +1,44 @@ +# METADATA +# description: Policy for updating a SCIM-provisioned user (ADR 0024 §3, §8) +package identity.scim.user.update + +import data.identity + +# Full-replace update (`PUT`) of a SCIM-provisioned user. +# +# The `input.existing.user` is the stored `UserResponse`. +# +# The Ownership Fencing Algorithm (ADR 0024 §3.C) has already run in the +# handler before this policy is evaluated — this is a second, independent +# authorization check, not a substitute for it (§8). +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +allow if { + "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "updating a SCIM user in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "roles", "msg": "updating a SCIM user requires a scim_provisioner or manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + not "scim_provisioner" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim/user/update_test.rego b/policy/identity/scim/user/update_test.rego new file mode 100644 index 000000000..e0669cafe --- /dev/null +++ b/policy/identity/scim/user/update_test.rego @@ -0,0 +1,15 @@ +package test_scim_user_update + +import data.identity.scim.user.update + +test_allowed if { + update.allow with input as {"credentials": {"roles": ["admin"]}} + update.allow with input as {"credentials": {"roles": [], "is_admin": true}} + update.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo"}}} +} + +test_forbidden if { + not update.allow with input as {"credentials": {"roles": []}} + not update.allow with input as {"credentials": {"roles": ["scim_provisioner"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo1"}}} + not update.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"user": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim_realm/create.rego b/policy/identity/scim_realm/create.rego new file mode 100644 index 000000000..bb4d0280d --- /dev/null +++ b/policy/identity/scim_realm/create.rego @@ -0,0 +1,45 @@ +# METADATA +# description: Policy for registering a new SCIM realm (ADR 0024 §2.A) +package identity.scim_realm.create + +import data.identity + +# Register a SCIM realm. +# +# The `input.target.scim_realm` is the creation payload: +# domain_id: string Domain owning the realm. +# provider_id: string The provider_id coordinate this realm authorizes. +# display_name: string Administrative display name. +# +# The `input.existing` is null +# +# Per ADR 0021 §5.A / ADR 0024 §8, realm CRUD requires the `manager` role +# (domain-scoped) or `admin` (cross-domain). Realm CRUD is invoked by a +# Fernet-authenticated human operator, not the SCIM API key itself, so +# `manager`/`admin` here can be backed by a real RoleAssignment. +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "registering a SCIM realm in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + "manager" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "registering a SCIM realm requires a manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim_realm/create_test.rego b/policy/identity/scim_realm/create_test.rego new file mode 100644 index 000000000..4e17a9130 --- /dev/null +++ b/policy/identity/scim_realm/create_test.rego @@ -0,0 +1,16 @@ +package test_scim_realm_create + +import data.identity.scim_realm.create + +test_allowed if { + create.allow with input as {"credentials": {"roles": ["admin"]}} + create.allow with input as {"credentials": {"roles": [], "is_admin": true}} + create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"scim_realm": {"domain_id": "foo"}}} +} + +test_forbidden if { + not create.allow with input as {"credentials": {"roles": []}} + not create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"scim_realm": {"domain_id": "foo1"}}} + not create.allow with input as {"credentials": {"roles": ["manager"]}, "target": {"scim_realm": {"domain_id": "foo"}}} + not create.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"scim_realm": {"domain_id": "foo"}}} +} diff --git a/policy/identity/scim_realm/disable.rego b/policy/identity/scim_realm/disable.rego new file mode 100644 index 000000000..cf344dd9c --- /dev/null +++ b/policy/identity/scim_realm/disable.rego @@ -0,0 +1,41 @@ +# METADATA +# description: Policy for updating a SCIM realm, including the enable/disable +# toggle (ADR 0024 §2.B, the Realm Activation Gate) +package identity.scim_realm.disable + +import data.identity + +# Update (including enable/disable) a SCIM realm. +# +# The `input.target.scim_realm` is the update patch (ScimRealmUpdate): +# display_name: string (optional) New display name. +# enabled: bool (optional) Enable/disable toggle. +# +# The `input.existing.scim_realm` is the stored realm object. +# +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "updating a SCIM realm in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + "manager" in input.credentials.roles + input.existing.scim_realm.domain_id != input.credentials.domain_id +} + +violation contains {"field": "domain_id", "msg": "updating a SCIM realm requires a manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim_realm/disable_test.rego b/policy/identity/scim_realm/disable_test.rego new file mode 100644 index 000000000..3c9fe4507 --- /dev/null +++ b/policy/identity/scim_realm/disable_test.rego @@ -0,0 +1,16 @@ +package test_scim_realm_disable + +import data.identity.scim_realm.disable + +test_allowed if { + disable.allow with input as {"credentials": {"roles": ["admin"]}} + disable.allow with input as {"credentials": {"roles": [], "is_admin": true}} + disable.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "existing": {"scim_realm": {"domain_id": "foo"}}} +} + +test_forbidden if { + not disable.allow with input as {"credentials": {"roles": []}} + not disable.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "existing": {"scim_realm": {"domain_id": "foo1"}}} + not disable.allow with input as {"credentials": {"roles": ["manager"]}, "existing": {"scim_realm": {"domain_id": "foo"}}} + not disable.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"scim_realm": {"domain_id": "foo2"}}} +} diff --git a/policy/identity/scim_realm/list.rego b/policy/identity/scim_realm/list.rego new file mode 100644 index 000000000..48d118ecc --- /dev/null +++ b/policy/identity/scim_realm/list.rego @@ -0,0 +1,40 @@ +# METADATA +# description: Policy for listing SCIM realms (ADR 0024 §2.A) +package identity.scim_realm.list + +import data.identity + +# List SCIM realms. +# +# The `input.target.scim_realm` contains query parameters: +# domain_id: string Domain to list realms for. +# enabled: bool (optional) Filter by enabled/disabled state. +# +# The `input.existing` is null +# +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "listing SCIM realms in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + "manager" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "listing SCIM realms requires a manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim_realm/list_test.rego b/policy/identity/scim_realm/list_test.rego new file mode 100644 index 000000000..951588180 --- /dev/null +++ b/policy/identity/scim_realm/list_test.rego @@ -0,0 +1,16 @@ +package test_scim_realm_list + +import data.identity.scim_realm.list + +test_allowed if { + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": [], "is_admin": true}} + list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"scim_realm": {"domain_id": "foo"}}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"scim_realm": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["manager"]}, "target": {"scim_realm": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"scim_realm": {"domain_id": "foo2"}}} +} diff --git a/policy/identity/scim_realm/show.rego b/policy/identity/scim_realm/show.rego new file mode 100644 index 000000000..bc080152a --- /dev/null +++ b/policy/identity/scim_realm/show.rego @@ -0,0 +1,42 @@ +# METADATA +# description: Policy for viewing a SCIM realm (ADR 0024 §2.A) +package identity.scim_realm.show + +import data.identity + +# Show a SCIM realm. +# +# The `input.existing.scim_realm` is the stored realm object: +# domain_id: string Domain owning the realm. +# provider_id: string The provider_id coordinate this realm authorizes. +# display_name: string Administrative display name. +# enabled: bool Whether the realm currently authorizes provisioning. +# +# The `input.target` is null +# +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + input.credentials.is_admin +} + +allow if { + "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "reading a SCIM realm in a domain different to the domain scope requires `admin` role."} if { + not "admin" in input.credentials.roles + "manager" in input.credentials.roles + not identity.domain_matches_domain_scope +} + +violation contains {"field": "domain_id", "msg": "reading a SCIM realm requires a manager role with the domain scope."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + identity.domain_matches_domain_scope +} diff --git a/policy/identity/scim_realm/show_test.rego b/policy/identity/scim_realm/show_test.rego new file mode 100644 index 000000000..2b16839e1 --- /dev/null +++ b/policy/identity/scim_realm/show_test.rego @@ -0,0 +1,16 @@ +package test_scim_realm_show + +import data.identity.scim_realm.show + +test_allowed if { + show.allow with input as {"credentials": {"roles": ["admin"]}} + show.allow with input as {"credentials": {"roles": [], "is_admin": true}} + show.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "existing": {"scim_realm": {"domain_id": "foo"}}} +} + +test_forbidden if { + not show.allow with input as {"credentials": {"roles": []}} + not show.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "existing": {"scim_realm": {"domain_id": "foo1"}}} + not show.allow with input as {"credentials": {"roles": ["manager"]}, "existing": {"scim_realm": {"domain_id": "foo"}}} + not show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "existing": {"scim_realm": {"domain_id": "foo2"}}} +} diff --git a/tests/integration/src/api_key/ingress.rs b/tests/integration/src/api_key/ingress.rs index 4ff2a9586..eded8b5ca 100644 --- a/tests/integration/src/api_key/ingress.rs +++ b/tests/integration/src/api_key/ingress.rs @@ -43,7 +43,7 @@ use openstack_keystone_core_types::role::RoleRefBuilder; use super::{create_api_key, sample_api_key_create}; use crate::common::{AsyncResourceGuard, get_state}; -use crate::create_domain; +use crate::{create_domain, create_role}; /// Generate a real `kscim_...` bearer token, persist its `ApiClientResource` /// (with a genuine Argon2id `secret_hash`, unlike `sample_api_key_create`'s @@ -70,6 +70,14 @@ async fn provision_working_api_key( create.allowed_ips = allowed_ips; let guard = create_api_key(state, create).await?; + // `validate_roles_exist` (crates/core/src/mapping/service.rs) rejects a + // ruleset that references a role id that doesn't exist yet, so the + // `member` role referenced below must be created first. The role is + // scoped to the per-test isolated database, so leaking its guard (rather + // than binding it to a variable that lives for the test's duration) is + // fine -- there's nothing else that will race to delete it. + let member_role = create_role!(state, "member")?; + let ruleset = MappingRuleSetCreate { mapping_id: Some(uuid::Uuid::new_v4().simple().to_string()), domain_id: Some(domain.id.clone()), @@ -93,8 +101,8 @@ async fn provision_working_api_key( domain_id: domain.id.clone(), roles: vec![ RoleRefBuilder::default() - .id("member") - .name("member") + .id(member_role.id.clone()) + .name(member_role.name.clone()) .build() .unwrap(), ], diff --git a/tests/integration/src/mapping/spiffe.rs b/tests/integration/src/mapping/spiffe.rs index e7976e56b..fcc841036 100644 --- a/tests/integration/src/mapping/spiffe.rs +++ b/tests/integration/src/mapping/spiffe.rs @@ -24,8 +24,8 @@ use openstack_keystone_core_types::mapping::rule::{ClaimCondition, MatchConditio use openstack_keystone_core_types::mapping::*; use crate::common::*; -use crate::create_domain; use crate::mapping::ruleset::create_ruleset; +use crate::{create_domain, create_role}; /// Construct a SPIFFE ruleset creation payload. fn spiffe_ruleset_create(domain_id: Option) -> MappingRuleSetCreate { @@ -454,6 +454,10 @@ async fn test_spiffe_all_of_strict_with_auth() -> Result<()> { let domain = create_domain!(state)?; let domain_id = domain.id.clone(); + // `validate_roles_exist` rejects a ruleset referencing a role id that + // doesn't exist yet. + let role = create_role!(state, uuid::Uuid::new_v4().simple().to_string())?; + let mut ruleset_create = spiffe_ruleset_create(Some(domain_id.clone())); ruleset_create.rules = vec![MappingRule { name: "spiffe-strict-rule".into(), @@ -482,8 +486,8 @@ async fn test_spiffe_all_of_strict_with_auth() -> Result<()> { openstack_keystone_core_types::mapping::authorization::Authorization::Domain { domain_id: domain_id.clone(), roles: vec![openstack_keystone_core_types::role::RoleRef { - id: "reader-role".into(), - name: Some("reader".into()), + id: role.id.clone(), + name: Some(role.name.clone()), domain_id: None, }], },