mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 20:47:01 +01:00
20240810 SCIM entry basic (#3032)
This commit is contained in:
parent
f053ff7fba
commit
d3891e301f
|
@ -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<ScimEntryGeneric, ClientError> {
|
||||
self.perform_get_request(format!("/scim/v1/Entry/{}", name_or_uuid).as_str())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<Attribute, JsonValue>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
@ -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<Attribute, JsonValue>,
|
||||
}
|
||||
|
||||
/// A strongly typed ScimEntry that is for transmission to clients. This uses
|
||||
/// Kanidm internal strong types for values allowing direct serialisation and
|
||||
/// transmission.
|
||||
|
|
|
@ -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<ScimEntryKanidm, OperationError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<Vec<String>>, 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<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
Path((id, attr)): Path<(String, String)>,
|
||||
) -> Result<Json<Option<Vec<String>>>, 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<String>,
|
||||
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<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
Path((id, attr)): Path<(String, String)>,
|
||||
Json(values): Json<Vec<String>>,
|
||||
) -> Result<Json<()>, 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<Vec<String>>, 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<ServerState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
Path((id, attr)): Path<(String, String)>,
|
||||
) -> Result<Json<Option<Vec<String>>>, 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<String>,
|
||||
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<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
Path((id, attr)): Path<(String, String)>,
|
||||
Json(values): Json<Vec<String>>,
|
||||
) -> Result<Json<()>, 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<Json<ScimEntryKanidm>, 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<ServerState> {
|
||||
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<ServerState> {
|
|||
// 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<ServerState> {
|
|||
//
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -2433,6 +2433,8 @@ impl Entry<EntryReduced, EntryCommitted> {
|
|||
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();
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue