20240810 SCIM entry basic (#3032)

This commit is contained in:
Firstyear 2024-09-12 12:53:43 +10:00 committed by GitHub
parent f053ff7fba
commit d3891e301f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 207 additions and 81 deletions

View file

@ -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
}
}

View file

@ -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,

View file

@ -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 {

View file

@ -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.

View file

@ -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())
}
}

View file

@ -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,

View file

@ -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
}

View file

@ -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();

View file

@ -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)
}
}
}
}

View file

@ -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()
);
}