From d3891e301f6382f8b77526cf88aa7718744197ea Mon Sep 17 00:00:00 2001 From: Firstyear Date: Thu, 12 Sep 2024 12:53:43 +1000 Subject: [PATCH] 20240810 SCIM entry basic (#3032) --- libs/client/src/scim.rs | 12 ++- proto/src/internal/error.rs | 2 + proto/src/scim_v1/mod.rs | 24 ++++- proto/src/scim_v1/server.rs | 13 +-- server/core/src/actors/v1_scim.rs | 34 +++++- server/core/src/https/apidocs/mod.rs | 2 + server/core/src/https/v1_scim.rs | 153 ++++++++++++++++----------- server/lib/src/entry.rs | 2 + server/lib/src/server/mod.rs | 9 +- server/testkit/tests/scim_test.rs | 37 +++++++ 10 files changed, 207 insertions(+), 81 deletions(-) diff --git a/libs/client/src/scim.rs b/libs/client/src/scim.rs index 6102da465..d3607382a 100644 --- a/libs/client/src/scim.rs +++ b/libs/client/src/scim.rs @@ -1,5 +1,5 @@ use crate::{ClientError, KanidmClient}; -use kanidm_proto::scim_v1::{ScimSyncRequest, ScimSyncState}; +use kanidm_proto::scim_v1::{ScimEntryGeneric, ScimSyncRequest, ScimSyncState}; impl KanidmClient { // TODO: testing for this @@ -15,4 +15,14 @@ impl KanidmClient { self.perform_post_request("/scim/v1/Sync", scim_sync_request) .await } + + /// Retrieve a Generic SCIM Entry as a JSON Value. This can retrieve any + /// type of entry that Kanidm supports. + pub async fn scim_v1_entry_get( + &self, + name_or_uuid: &str, + ) -> Result { + self.perform_get_request(format!("/scim/v1/Entry/{}", name_or_uuid).as_str()) + .await + } } diff --git a/proto/src/internal/error.rs b/proto/src/internal/error.rs index f893c4e3f..b1c327ad4 100644 --- a/proto/src/internal/error.rs +++ b/proto/src/internal/error.rs @@ -73,6 +73,7 @@ pub enum OperationError { Backend, NoMatchingEntries, NoMatchingAttributes, + UniqueConstraintViolation, CorruptedEntry(u64), CorruptedIndex(String), // TODO: this should just be a vec of the ConsistencyErrors, surely? @@ -256,6 +257,7 @@ impl OperationError { Self::Backend => None, Self::NoMatchingEntries => None, Self::NoMatchingAttributes => None, + Self::UniqueConstraintViolation => Some("A unique constraint was violated resulting in multiple conflicting results."), Self::CorruptedEntry(_) => None, Self::CorruptedIndex(_) => None, Self::ConsistencyError(_) => None, diff --git a/proto/src/scim_v1/mod.rs b/proto/src/scim_v1/mod.rs index 539f82142..919cd90e4 100644 --- a/proto/src/scim_v1/mod.rs +++ b/proto/src/scim_v1/mod.rs @@ -16,15 +16,29 @@ //! The server module, which describes how a server should transmit entries and //! how it should recieve them. +use crate::attribute::Attribute; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use utoipa::ToSchema; + +pub use self::synch::*; +pub use scim_proto::prelude::*; + mod client; pub mod server; mod synch; -pub use scim_proto::prelude::*; - -pub use self::synch::*; - -// +/// A generic ScimEntry. This retains attribute +/// values in a generic state awaiting processing by schema aware transforms +/// either by the server or the client. +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct ScimEntryGeneric { + #[serde(flatten)] + pub header: ScimEntryHeader, + #[serde(flatten)] + pub attrs: BTreeMap, +} #[cfg(test)] mod tests { diff --git a/proto/src/scim_v1/server.rs b/proto/src/scim_v1/server.rs index 676b75769..c75ad3cb0 100644 --- a/proto/src/scim_v1/server.rs +++ b/proto/src/scim_v1/server.rs @@ -1,7 +1,6 @@ use crate::attribute::Attribute; use scim_proto::ScimEntryHeader; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; +use serde::Serialize; use serde_with::{base64, formats, hex::Hex, serde_as, skip_serializing_none, StringWithSeparator}; use std::collections::{BTreeMap, BTreeSet}; use time::format_description::well_known::Rfc3339; @@ -10,16 +9,6 @@ use url::Url; use utoipa::ToSchema; use uuid::Uuid; -/// A generic ScimEntry that we receive from a client. This retains attribute -/// values in a generic state awaiting processing by schema aware transforms -#[derive(Deserialize, Debug, Clone, ToSchema)] -pub struct ScimEntryGeneric { - #[serde(flatten)] - pub header: ScimEntryHeader, - #[serde(flatten)] - pub attrs: BTreeMap, -} - /// A strongly typed ScimEntry that is for transmission to clients. This uses /// Kanidm internal strong types for values allowing direct serialisation and /// transmission. diff --git a/server/core/src/actors/v1_scim.rs b/server/core/src/actors/v1_scim.rs index b45a06955..cadb41e29 100644 --- a/server/core/src/actors/v1_scim.rs +++ b/server/core/src/actors/v1_scim.rs @@ -5,7 +5,7 @@ use kanidmd_lib::idm::scim::{ }; use kanidmd_lib::idm::server::IdmServerTransaction; -use kanidm_proto::scim_v1::{ScimSyncRequest, ScimSyncState}; +use kanidm_proto::scim_v1::{server::ScimEntryKanidm, ScimSyncRequest, ScimSyncState}; use super::{QueryServerReadV1, QueryServerWriteV1}; @@ -197,4 +197,36 @@ impl QueryServerReadV1 { idms_prox_read.scim_sync_get_state(&ident) } + + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn scim_entry_id_get( + &self, + client_auth_info: ClientAuthInfo, + eventid: Uuid, + uuid_or_name: String, + ) -> Result { + let ct = duration_from_epoch_now(); + let mut idms_prox_read = self.idms.proxy_read().await?; + let ident = idms_prox_read + .validate_client_auth_info_to_ident(client_auth_info, ct) + .inspect_err(|err| { + error!(?err, "Invalid identity"); + })?; + + let target_uuid = idms_prox_read + .qs_read + .name_to_uuid(uuid_or_name.as_str()) + .inspect_err(|err| { + error!(?err, "Error resolving id to target"); + })?; + + idms_prox_read + .qs_read + .impersonate_search_ext_uuid(target_uuid, &ident) + .and_then(|entry| entry.to_scim_kanidm()) + } } diff --git a/server/core/src/https/apidocs/mod.rs b/server/core/src/https/apidocs/mod.rs index 199376851..ad70ca2a4 100644 --- a/server/core/src/https/apidocs/mod.rs +++ b/server/core/src/https/apidocs/mod.rs @@ -73,8 +73,10 @@ impl Modify for SecurityAddon { super::v1_oauth2::oauth2_id_claimmap_join_post, super::v1_oauth2::oauth2_id_claimmap_post, super::v1_oauth2::oauth2_id_claimmap_delete, + super::v1_scim::scim_sync_post, super::v1_scim::scim_sync_get, + super::v1_scim::scim_entry_id_get, super::v1::schema_get, super::v1::whoami, diff --git a/server/core/src/https/v1_scim.rs b/server/core/src/https/v1_scim.rs index b95fb3564..1573cebe2 100644 --- a/server/core/src/https/v1_scim.rs +++ b/server/core/src/https/v1_scim.rs @@ -11,7 +11,7 @@ use axum::extract::{Path, State}; use axum::response::Html; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; -use kanidm_proto::scim_v1::{ScimSyncRequest, ScimSyncState}; +use kanidm_proto::scim_v1::{server::ScimEntryKanidm, ScimSyncRequest, ScimSyncState}; use kanidm_proto::v1::Entry as ProtoEntry; use kanidmd_lib::prelude::*; @@ -206,6 +206,54 @@ pub async fn sync_account_token_delete( .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/sync_account/{id}/_attr/{attr}", + responses( + (status = 200, body=Option>, content_type="application/json"), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", + operation_id = "sync_account_id_attr_get" +)] +pub async fn sync_account_id_attr_get( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Path((id, attr)): Path<(String, String)>, +) -> Result>>, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); + json_rest_event_get_id_attr(state, id, attr, filter, kopid, client_auth_info).await +} + +#[utoipa::path( + post, + path = "/v1/sync_account/{id}/_attr/{attr}", + request_body=Vec, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", + operation_id = "sync_account_id_attr_put" +)] +pub async fn sync_account_id_attr_put( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Path((id, attr)): Path<(String, String)>, + Json(values): Json>, +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); + json_rest_event_put_attr(state, id, attr, filter, values, kopid, client_auth_info).await +} + +/// When you want the kitchen Sink +async fn scim_sink_get() -> Html<&'static str> { + Html::from(include_str!("scim/sink.html")) +} + #[utoipa::path( post, path = "/scim/v1/Sync", @@ -255,56 +303,58 @@ async fn scim_sync_get( .map(Json::from) .map_err(WebError::from) } + #[utoipa::path( get, - path = "/v1/sync_account/{id}/_attr/{attr}", + path = "/scim/v1/Entry/{id}", responses( - (status = 200, body=Option>, content_type="application/json"), + (status = 200, content_type="application/json", body=ScimEntry), ApiResponseWithout200, ), security(("token_jwt" = [])), - tag = "v1/sync_account", - operation_id = "sync_account_id_attr_get" + tag = "scim", + operation_id = "scim_entry_id_get" )] -pub async fn sync_account_id_attr_get( +async fn scim_entry_id_get( State(state): State, + Path(id): Path, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, - Path((id, attr)): Path<(String, String)>, -) -> Result>>, WebError> { - let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); - json_rest_event_get_id_attr(state, id, attr, filter, kopid, client_auth_info).await -} - -#[utoipa::path( - post, - path = "/v1/sync_account/{id}/_attr/{attr}", - request_body=Vec, - responses( - DefaultApiResponse, - ), - security(("token_jwt" = [])), - tag = "v1/sync_account", - operation_id = "sync_account_id_attr_put" -)] -pub async fn sync_account_id_attr_put( - State(state): State, - Extension(kopid): Extension, - VerifiedClientInformation(client_auth_info): VerifiedClientInformation, - Path((id, attr)): Path<(String, String)>, - Json(values): Json>, -) -> Result, WebError> { - let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); - json_rest_event_put_attr(state, id, attr, filter, values, kopid, client_auth_info).await -} - -/// When you want the kitchen Sink -async fn scim_sink_get() -> Html<&'static str> { - Html::from(include_str!("scim/sink.html")) +) -> Result, WebError> { + state + .qe_r_ref + .scim_entry_id_get(client_auth_info, kopid.eventid, id) + .await + .map(Json::from) + .map_err(WebError::from) } pub fn route_setup() -> Router { Router::new() + .route( + "/v1/sync_account", + get(sync_account_get).post(sync_account_post), + ) + .route( + "/v1/sync_account/:id", + get(sync_account_id_get).patch(sync_account_id_patch), + ) + .route( + "/v1/sync_account/:id/_attr/:attr", + get(sync_account_id_attr_get).put(sync_account_id_attr_put), + ) + .route( + "/v1/sync_account/:id/_finalise", + get(sync_account_id_finalise_get), + ) + .route( + "/v1/sync_account/:id/_terminate", + get(sync_account_id_terminate_get), + ) + .route( + "/v1/sync_account/:id/_sync_token", + post(sync_account_token_post).delete(sync_account_token_delete), + ) // https://datatracker.ietf.org/doc/html/rfc7644#section-3.2 // // HTTP SCIM Usage @@ -366,6 +416,11 @@ pub fn route_setup() -> Router { // POST. // -- Kanidm Resources // + // Entry /Entry/{id} GET Retrieve a generic entry + // of any kind from the database. + // {id} is any unique id. + .route("/scim/v1/Entry/:id", get(scim_entry_id_get)) + // // Sync /Sync GET Retrieve the current // sync state associated // with the authenticated @@ -373,30 +428,6 @@ pub fn route_setup() -> Router { // // POST Send a sync update // - .route( - "/v1/sync_account", - get(sync_account_get).post(sync_account_post), - ) - .route( - "/v1/sync_account/:id", - get(sync_account_id_get).patch(sync_account_id_patch), - ) - .route( - "/v1/sync_account/:id/_attr/:attr", - get(sync_account_id_attr_get).put(sync_account_id_attr_put), - ) - .route( - "/v1/sync_account/:id/_finalise", - get(sync_account_id_finalise_get), - ) - .route( - "/v1/sync_account/:id/_terminate", - get(sync_account_id_terminate_get), - ) - .route( - "/v1/sync_account/:id/_sync_token", - post(sync_account_token_post).delete(sync_account_token_delete), - ) .route("/scim/v1/Sync", post(scim_sync_post).get(scim_sync_get)) .route("/scim/v1/Sink", get(scim_sink_get)) // skip_route_check } diff --git a/server/lib/src/entry.rs b/server/lib/src/entry.rs index 531d92bba..7f87289b6 100644 --- a/server/lib/src/entry.rs +++ b/server/lib/src/entry.rs @@ -2433,6 +2433,8 @@ impl Entry { let attrs = self .attrs .iter() + // We want to skip some attributes as they are already in the header. + .filter(|(k, _vs)| **k != Attribute::Uuid) .filter_map(|(k, vs)| vs.to_scim_value().map(|scim_value| (k.clone(), scim_value))) .collect(); diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index ce3e3d6a5..82494f5fa 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -586,7 +586,14 @@ pub trait QueryServerTransaction<'a> { let mut vs = self.impersonate_search_ext(filter, filter_intent, event)?; match vs.pop() { Some(entry) if vs.is_empty() => Ok(entry), - _ => Err(OperationError::NoMatchingEntries), + _ => { + if vs.is_empty() { + Err(OperationError::NoMatchingEntries) + } else { + // Multiple entries matched, should not be possible! + Err(OperationError::UniqueConstraintViolation) + } + } } } diff --git a/server/testkit/tests/scim_test.rs b/server/testkit/tests/scim_test.rs index 34e71227b..37d8ae518 100644 --- a/server/testkit/tests/scim_test.rs +++ b/server/testkit/tests/scim_test.rs @@ -1,6 +1,7 @@ use compact_jwt::{traits::JwsVerifiable, JwsCompact, JwsEs256Verifier, JwsVerifier}; use kanidm_client::KanidmClient; use kanidm_proto::internal::ScimSyncToken; +use kanidmd_lib::prelude::{Attribute, BUILTIN_GROUP_IDM_ADMINS_V1}; use kanidmd_testkit::{ADMIN_TEST_PASSWORD, ADMIN_TEST_USER}; use reqwest::header::HeaderValue; use std::str::FromStr; @@ -158,3 +159,39 @@ async fn test_scim_sync_get(rsclient: KanidmClient) { assert!(content_type.to_str().unwrap().contains("text/html")); assert!(response.text().await.unwrap().contains("Sink")); } + +#[kanidmd_testkit::test] +async fn test_scim_sync_entry_get(rsclient: KanidmClient) { + let res = rsclient + .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .await; + assert!(res.is_ok()); + + // All admin to create persons. + rsclient + .idm_group_add_members(BUILTIN_GROUP_IDM_ADMINS_V1.name, &["admin"]) + .await + .unwrap(); + + rsclient + .idm_person_account_create("demo_account", "Deeeeemo") + .await + .unwrap(); + + // This will be as raw json, not the strongly typed version the server sees + // internally. + let scim_entry = rsclient.scim_v1_entry_get("demo_account").await.unwrap(); + + tracing::info!("{:#?}", scim_entry); + + assert!(scim_entry.attrs.contains_key(&Attribute::Class)); + assert!(scim_entry.attrs.contains_key(&Attribute::Name)); + assert_eq!( + scim_entry + .attrs + .get(&Attribute::Name) + .and_then(|v| v.as_str()) + .unwrap(), + "demo_account".to_string() + ); +}