use std::time::Duration;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use compact_jwt::{Jws, JwsCompact, JwsEs256Signer, JwsSigner};
use kanidm_proto::internal::{ApiTokenPurpose, ScimSyncToken};
use kanidm_proto::scim_v1::*;
use std::collections::{BTreeMap, BTreeSet};
use crate::credential::totp::{Totp, TotpAlgo, TotpDigits};
use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction};
use crate::prelude::*;
use crate::value::ApiToken;
use crate::schema::{SchemaClass, SchemaTransaction};
use sshkey_attest::proto::PublicKey as SshPublicKey;
#[allow(dead_code)]
pub(crate) struct SyncAccount {
pub name: String,
pub uuid: Uuid,
pub sync_tokens: BTreeMap<Uuid, ApiToken>,
pub jws_key: Option<JwsEs256Signer>,
}
macro_rules! try_from_entry {
($value:expr) => {{
if !$value.attribute_equality(Attribute::Class, &EntryClass::SyncAccount.into()) {
return Err(OperationError::MissingClass(ENTRYCLASS_SYNC_ACCOUNT.into()));
}
let name = $value
.get_ava_single_iname(Attribute::Name)
.map(|s| s.to_string())
.ok_or(OperationError::MissingAttribute(Attribute::Name))?;
let jws_key = $value
.get_ava_single_jws_key_es256(Attribute::JwsEs256PrivateKey)
.cloned()
.map(|jws_key| {
jws_key
.set_sign_option_embed_jwk(true)
.set_sign_option_legacy_kid(true)
});
let sync_tokens = $value
.get_ava_as_apitoken_map(Attribute::SyncTokenSession)
.cloned()
.unwrap_or_default();
let uuid = $value.get_uuid().clone();
Ok(SyncAccount {
name,
uuid,
sync_tokens,
jws_key,
})
}};
}
impl SyncAccount {
#[instrument(level = "debug", skip_all)]
pub(crate) fn try_from_entry_rw(
value: &Entry<EntrySealed, EntryCommitted>,
) -> Result<Self, OperationError> {
try_from_entry!(value)
}
pub(crate) fn check_sync_token_valid(
_ct: Duration,
sst: &ScimSyncToken,
entry: &Entry<EntrySealed, EntryCommitted>,
) -> bool {
let valid_purpose = matches!(sst.purpose, ApiTokenPurpose::Synchronise);
let session_present = entry
.get_ava_as_apitoken_map(Attribute::SyncTokenSession)
.map(|session_map| session_map.get(&sst.token_id).is_some())
.unwrap_or(false);
debug!(?session_present, valid_purpose);
session_present && valid_purpose
}
}
pub struct GenerateScimSyncTokenEvent {
pub ident: Identity,
pub target: Uuid,
pub label: String,
}
impl GenerateScimSyncTokenEvent {
#[cfg(test)]
pub fn new_internal(target: Uuid, label: &str) -> Self {
GenerateScimSyncTokenEvent {
ident: Identity::from_internal(),
target,
label: label.to_string(),
}
}
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
pub fn scim_sync_generate_token(
&mut self,
gte: &GenerateScimSyncTokenEvent,
ct: Duration,
) -> Result<JwsCompact, OperationError> {
let sync_account = self
.qs_write
.internal_search_uuid(gte.target)
.and_then(|entry| SyncAccount::try_from_entry_rw(&entry))
.map_err(|e| {
admin_error!(?e, "Failed to search service account");
e
})?;
let session_id = Uuid::new_v4();
let issued_at = time::OffsetDateTime::UNIX_EPOCH + ct;
let scope = ApiTokenScope::Synchronise;
let purpose = scope.try_into()?;
let session = Value::ApiToken(
session_id,
ApiToken {
label: gte.label.clone(),
expiry: None,
issued_at,
issued_by: gte.ident.get_event_origin_id(),
scope,
},
);
let scim_sync_token = ScimSyncToken {
token_id: session_id,
issued_at,
purpose,
};
let token = Jws::into_json(&scim_sync_token).map_err(|err| {
error!(?err, "Unable to serialise JWS");
OperationError::SerdeJsonError
})?;
let modlist =
ModifyList::new_list(vec![Modify::Present(Attribute::SyncTokenSession, session)]);
self.qs_write
.impersonate_modify(
&filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(gte.target))),
&filter_all!(f_eq(Attribute::Uuid, PartialValue::Uuid(gte.target))),
&modlist,
>e.ident,
)
.map_err(|err| {
error!(?err, "Failed to generate sync token");
err
})?;
if self.qs_write.get_domain_version() < DOMAIN_LEVEL_6 {
sync_account
.jws_key
.as_ref()
.ok_or_else(|| {
admin_error!("Unable to sign sync token, no sync keys available");
OperationError::CryptographyError
})
.and_then(|jws_key| {
jws_key.sign(&token).map_err(|err| {
admin_error!(?err, "Unable to sign sync token");
OperationError::CryptographyError
})
})
} else {
self.qs_write
.get_domain_key_object_handle()?
.jws_es256_sign(&token, ct)
}
}
pub fn sync_account_destroy_token(
&mut self,
ident: &Identity,
target: Uuid,
_ct: Duration,
) -> Result<(), OperationError> {
let modlist = ModifyList::new_list(vec![Modify::Purged(Attribute::SyncTokenSession)]);
self.qs_write
.impersonate_modify(
&filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(target))),
&filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(target))),
&modlist,
ident,
)
.map_err(|e| {
admin_error!("Failed to destroy api token {:?}", e);
e
})
}
}
pub struct ScimSyncFinaliseEvent {
pub ident: Identity,
pub target: Uuid,
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
pub fn scim_sync_finalise(
&mut self,
sfe: &ScimSyncFinaliseEvent,
) -> Result<(), OperationError> {
let entry = self
.qs_write
.internal_search_uuid(sfe.target)
.map_err(|e| {
admin_error!(?e, "Failed to search sync account");
e
})?;
let sync_account = SyncAccount::try_from_entry_rw(&entry).map_err(|e| {
admin_error!(?e, "Failed to convert sync account");
e
})?;
let sync_uuid = sync_account.uuid;
let effective_perms = self
.qs_write
.get_accesscontrols()
.effective_permission_check(&sfe.ident, Some(BTreeSet::default()), &[entry])?;
let eperm = effective_perms.first().ok_or_else(|| {
admin_error!("Effective Permission check returned no results");
OperationError::InvalidState
})?;
if eperm.target != sync_account.uuid {
admin_error!("Effective Permission check target differs from requested entry uuid");
return Err(OperationError::InvalidEntryState);
}
if !eperm.delete {
security_info!(
"Requester {} does not have permission to delete sync account {}",
sfe.ident,
sync_account.name
);
return Err(OperationError::NotAuthorised);
}
let f_all_sync = filter_all!(f_and!([
f_eq(Attribute::Class, EntryClass::SyncObject.into()),
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid))
]));
let existing_entries = self
.qs_write
.internal_exists(f_all_sync.clone())
.inspect_err(|_e| {
error!("Failed to determine existing entries set");
})?;
let delete_filter = filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(sync_uuid)));
if existing_entries {
let schema = self.qs_write.get_schema();
let sync_class = schema
.get_classes()
.get(EntryClass::SyncObject.into())
.ok_or_else(|| {
error!(
"Failed to access {} class, schema corrupt",
EntryClass::SyncObject
);
OperationError::InvalidState
})?;
let modlist = std::iter::once(Modify::Removed(
Attribute::Class,
EntryClass::SyncObject.into(),
))
.chain(
sync_class
.may_iter()
.map(|aname| Modify::Purged(aname.clone())),
)
.collect();
let mods = ModifyList::new_list(modlist);
self.qs_write
.internal_modify(&f_all_sync, &mods)
.inspect_err(|_e| {
error!("Failed to modify sync objects to grant authority to kanidm");
})?;
};
self.qs_write
.internal_delete(&delete_filter)
.inspect_err(|e| {
error!(?e, "Failed to terminate sync account");
})
}
}
pub struct ScimSyncTerminateEvent {
pub ident: Identity,
pub target: Uuid,
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
pub fn scim_sync_terminate(
&mut self,
ste: &ScimSyncTerminateEvent,
) -> Result<(), OperationError> {
let entry = self
.qs_write
.internal_search_uuid(ste.target)
.inspect_err(|e| {
admin_error!(?e, "Failed to search sync account");
})?;
let sync_account = SyncAccount::try_from_entry_rw(&entry).map_err(|e| {
admin_error!(?e, "Failed to convert sync account");
e
})?;
let sync_uuid = sync_account.uuid;
let effective_perms = self
.qs_write
.get_accesscontrols()
.effective_permission_check(&ste.ident, Some(BTreeSet::default()), &[entry])?;
let eperm = effective_perms.first().ok_or_else(|| {
admin_error!("Effective Permission check returned no results");
OperationError::InvalidState
})?;
if eperm.target != sync_account.uuid {
admin_error!("Effective Permission check target differs from requested entry uuid");
return Err(OperationError::InvalidEntryState);
}
if !eperm.delete {
security_info!(
"Requester {} does not have permission to delete sync account {}",
ste.ident,
sync_account.name
);
return Err(OperationError::NotAuthorised);
}
let f_all_sync = filter_all!(f_and!([
f_eq(Attribute::Class, EntryClass::SyncObject.into()),
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid))
]));
let existing_entries = self
.qs_write
.internal_search(f_all_sync.clone())
.inspect_err(|err| {
error!(?err, "Failed to determine existing entries set");
})?;
let delete_filter = if existing_entries.is_empty() {
filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(sync_uuid)))
} else {
let filter_or: Vec<_> = existing_entries
.iter()
.map(|e| f_eq(Attribute::Uuid, PartialValue::Uuid(e.get_uuid())))
.collect();
let schema = self.qs_write.get_schema();
let sync_class = schema
.get_classes()
.get(EntryClass::SyncObject.into())
.ok_or_else(|| {
error!(
"Failed to access {} class, schema corrupt",
EntryClass::SyncObject
);
OperationError::InvalidState
})?;
let modlist = std::iter::once(Modify::Removed(
Attribute::Class,
EntryClass::SyncObject.into(),
))
.chain(
sync_class
.may_iter()
.map(|aname| Modify::Purged(aname.clone())),
)
.collect();
let mods = ModifyList::new_list(modlist);
self.qs_write
.internal_modify(&f_all_sync, &mods)
.inspect_err(|err| {
error!(
?err,
"Failed to modify sync objects to grant authority to Kanidm"
);
})?;
filter!(f_or!([
f_eq(Attribute::Uuid, PartialValue::Uuid(sync_uuid)),
f_or(filter_or)
]))
};
self.qs_write.internal_delete(&delete_filter).map_err(|e| {
error!(?e, "Failed to terminate sync account");
e
})
}
}
pub struct ScimSyncUpdateEvent {
pub ident: Identity,
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
#[instrument(level = "info", skip_all)]
pub fn scim_sync_apply(
&mut self,
sse: &ScimSyncUpdateEvent,
changes: &ScimSyncRequest,
_ct: Duration,
) -> Result<(), OperationError> {
let (sync_uuid, sync_authority_set, change_entries, sync_refresh) =
self.scim_sync_apply_phase_1(sse, changes)?;
self.scim_sync_apply_phase_2(&change_entries, sync_uuid)?;
if sync_refresh {
self.scim_sync_apply_phase_refresh_cleanup(&change_entries, sync_uuid)?;
}
self.scim_sync_apply_phase_3(&change_entries, sync_uuid, &sync_authority_set)?;
self.scim_sync_apply_phase_4(&changes.retain, sync_uuid)?;
self.scim_sync_apply_phase_5(sync_uuid, &changes.to_state)
}
#[instrument(level = "debug", skip_all)]
fn scim_sync_apply_phase_1<'b>(
&mut self,
sse: &'b ScimSyncUpdateEvent,
changes: &'b ScimSyncRequest,
) -> Result<
(
Uuid,
BTreeSet<Attribute>,
BTreeMap<Uuid, &'b ScimEntry>,
bool,
),
OperationError,
> {
let sync_uuid = match &sse.ident.origin {
IdentType::User(_) | IdentType::Internal => {
warn!("Ident type is not synchronise");
return Err(OperationError::AccessDenied);
}
IdentType::Synch(u) => {
*u
}
};
match sse.ident.access_scope() {
AccessScope::ReadOnly | AccessScope::ReadWrite => {
warn!("Ident access scope is not synchronise");
return Err(OperationError::AccessDenied);
}
AccessScope::Synchronise => {
}
};
let sync_entry = self
.qs_write
.internal_search_uuid(sync_uuid)
.inspect_err(|err| {
error!(
?err,
"Failed to located sync entry related to {}", sync_uuid
);
})?;
match (
&changes.from_state,
sync_entry.get_ava_single_private_binary(Attribute::SyncCookie),
) {
(ScimSyncState::Refresh, _) => {
info!("Refresh Sync");
}
(ScimSyncState::Active { cookie }, Some(sync_cookie)) => {
if cookie != sync_cookie {
error!(
"Invalid Sync State - Active, but agreement has divegent external cookie."
);
return Err(OperationError::InvalidSyncState);
} else {
info!("Active Sync with valid cookie");
}
}
(ScimSyncState::Active { cookie: _ }, None) => {
error!("Invalid Sync State - Sync Tool Reports Active, but agreement has Refresh Required. You can resync the agreement with `kanidm system sync force-refresh`");
return Err(OperationError::InvalidSyncState);
}
};
let sync_refresh = matches!(&changes.from_state, ScimSyncState::Refresh);
let sync_authority_set: BTreeSet<Attribute> = sync_entry
.get_ava_as_iutf8(Attribute::SyncYieldAuthority)
.map(|set| set.iter().map(|s| Attribute::from(s.as_str())).collect())
.unwrap_or_default();
let change_entries: BTreeMap<Uuid, &ScimEntry> = changes
.entries
.iter()
.map(|scim_entry| (scim_entry.id, scim_entry))
.collect();
Ok((sync_uuid, sync_authority_set, change_entries, sync_refresh))
}
#[instrument(level = "debug", skip_all)]
pub(crate) fn scim_sync_apply_phase_2(
&mut self,
change_entries: &BTreeMap<Uuid, &ScimEntry>,
sync_uuid: Uuid,
) -> Result<(), OperationError> {
if change_entries.is_empty() {
info!("No change_entries requested");
return Ok(());
}
let filter_or = change_entries
.keys()
.copied()
.map(|u| f_eq(Attribute::Uuid, PartialValue::Uuid(u)))
.collect();
let existing_entries = self
.qs_write
.internal_search(filter_all!(f_or(filter_or)))
.inspect_err(|err| {
error!(?err, "Failed to determine existing entries set");
})?;
let mut fail = false;
existing_entries.iter().for_each(|e| {
if e.mask_recycled_ts().is_none() {
error!("Unable to proceed: entry uuid {} ({}) is masked. You must re-map this entries uuid in the sync connector to proceed.", e.get_uuid(), e.get_display_id());
fail = true;
}
});
if fail {
return Err(OperationError::InvalidEntryState);
}
let mut missing_scim = change_entries.clone();
existing_entries.iter().for_each(|entry| {
missing_scim.remove(&entry.get_uuid());
});
let create_stubs: Vec<EntryInitNew> = missing_scim
.keys()
.copied()
.map(|u| {
entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::SyncObject.to_value()),
(Attribute::SyncParentUuid, Value::Refer(sync_uuid)),
(Attribute::Uuid, Value::Uuid(u))
)
})
.collect();
if !create_stubs.is_empty() {
self.qs_write
.internal_create(create_stubs)
.inspect_err(|err| {
error!(?err, "Unable to create stub entries");
})?;
}
self.qs_write
.internal_batch_modify(change_entries.iter().filter_map(|(u, scim_ent)| {
scim_ent.external_id.as_ref().map(|ext_id| {
(
*u,
ModifyList::new_list(vec![
Modify::Assert(
Attribute::SyncParentUuid,
PartialValue::Refer(sync_uuid),
),
Modify::Purged(Attribute::SyncExternalId),
Modify::Present(Attribute::SyncExternalId, Value::new_iutf8(ext_id)),
]),
)
})
}))
.inspect_err(|err| {
error!(?err, "Unable to setup external ids from sync entries");
})?;
Ok(())
}
#[instrument(level = "debug", skip_all)]
pub(crate) fn scim_sync_apply_phase_refresh_cleanup(
&mut self,
change_entries: &BTreeMap<Uuid, &ScimEntry>,
sync_uuid: Uuid,
) -> Result<(), OperationError> {
let filter_or = change_entries
.keys()
.copied()
.map(|u| f_eq(Attribute::Uuid, PartialValue::Uuid(u)))
.collect::<Vec<_>>();
let delete_filter = if filter_or.is_empty() {
filter!(f_and!([
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid))
]))
} else {
filter!(f_and!([
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid)),
f_andnot(f_or(filter_or))
]))
};
self.qs_write
.internal_delete(&delete_filter)
.or_else(|err| {
if err == OperationError::NoMatchingEntries {
Ok(())
} else {
Err(err)
}
})
.map_err(|e| {
error!(?e, "Failed to delete dangling uuids");
e
})
}
fn scim_attr_to_values(
&mut self,
scim_attr_name: &Attribute,
scim_attr: &ScimValue,
) -> Result<Vec<Value>, OperationError> {
let schema = self.qs_write.get_schema();
let attr_schema = schema.get_attributes().get(scim_attr_name).ok_or_else(|| {
OperationError::InvalidAttribute(format!(
"No such attribute in schema - {scim_attr_name}"
))
})?;
match (attr_schema.syntax, attr_schema.multivalue, scim_attr) {
(
SyntaxType::Utf8StringIname,
false,
ScimValue::Simple(ScimAttr::String(value)),
) => Ok(vec![Value::new_iname(value)]),
(
SyntaxType::Utf8String,
false,
ScimValue::Simple(ScimAttr::String(value)),
) => Ok(vec![Value::new_utf8(value.clone())]),
(
SyntaxType::Utf8StringInsensitive,
false,
ScimValue::Simple(ScimAttr::String(value)),
) => Ok(vec![Value::new_iutf8(value)]),
(
SyntaxType::Uint32,
false,
ScimValue::Simple(ScimAttr::Integer(int_value)),
) => u32::try_from(*int_value).map_err(|_| {
error!("Invalid value - not within the bounds of a u32");
OperationError::InvalidAttribute(format!(
"Out of bounds unsigned integer - {scim_attr_name}"
))
})
.map(|value| vec![Value::Uint32(value)]),
(SyntaxType::ReferenceUuid, true, ScimValue::MultiComplex(values)) => {
let mut vs = Vec::with_capacity(values.len());
for complex in values.iter() {
let external_id = complex.get("external_id").ok_or_else(|| {
error!("Invalid scim complex attr - missing required key external_id");
OperationError::InvalidAttribute(format!(
"missing required key external_id - {scim_attr_name}"
))
})?;
let value = match external_id {
ScimAttr::String(value) => Ok(value.as_str()),
_ => {
error!("Invalid external_id attribute - must be scim simple string");
Err(OperationError::InvalidAttribute(format!(
"external_id must be scim simple string - {scim_attr_name}"
)))
}
}?;
let maybe_uuid =
self.qs_write.sync_external_id_to_uuid(value).map_err(|e| {
error!(?e, "Unable to resolve external_id to uuid");
e
})?;
if let Some(uuid) = maybe_uuid {
vs.push(Value::Refer(uuid))
} else {
debug!("Could not convert external_id to reference - {}", value);
}
}
Ok(vs)
}
(SyntaxType::TotpSecret, true, ScimValue::MultiComplex(values)) => {
let mut vs = Vec::with_capacity(values.len());
for complex in values.iter() {
let external_id = complex
.get("external_id")
.ok_or_else(|| {
error!("Invalid scim complex attr - missing required key external_id");
OperationError::InvalidAttribute(format!(
"missing required key external_id - {scim_attr_name}"
))
})
.and_then(|external_id| match external_id {
ScimAttr::String(value) => Ok(value.clone()),
_ => {
error!(
"Invalid external_id attribute - must be scim simple string"
);
Err(OperationError::InvalidAttribute(format!(
"external_id must be scim simple string - {scim_attr_name}"
)))
}
})?;
let secret = complex
.get(SCIM_SECRET)
.ok_or_else(|| {
error!("Invalid SCIM complex attr - missing required key secret");
OperationError::InvalidAttribute(format!(
"missing required key secret - {scim_attr_name}"
))
})
.and_then(|secret| match secret {
ScimAttr::String(value) => {
STANDARD.decode(value.as_str())
.map_err(|_| {
error!("Invalid secret attribute - must be base64 string");
OperationError::InvalidAttribute(format!(
"secret must be base64 string - {scim_attr_name}"
))
})
}
_ => {
error!("Invalid secret attribute - must be scim simple string");
Err(OperationError::InvalidAttribute(format!(
"secret must be scim simple string - {scim_attr_name}"
)))
}
})?;
let algo = complex.get(SCIM_ALGO)
.ok_or_else(|| {
error!("Invalid scim complex attr - missing required key algo");
OperationError::InvalidAttribute(format!(
"missing required key algo - {scim_attr_name}"
))
})
.and_then(|algo_str| {
match algo_str {
ScimAttr::String(value) => {
match value.as_str() {
"sha1" => Ok(TotpAlgo::Sha1),
"sha256" => Ok(TotpAlgo::Sha256),
"sha512" => Ok(TotpAlgo::Sha512),
_ => {
error!("Invalid algo attribute - must be one of sha1, sha256 or sha512");
Err(OperationError::InvalidAttribute(format!(
"algo must be one of sha1, sha256 or sha512 - {scim_attr_name}"
)))
}
}
}
_ => {
error!("Invalid algo attribute - must be scim simple string");
Err(OperationError::InvalidAttribute(format!(
"algo must be scim simple string - {scim_attr_name}"
)))
}
}
})?;
let step = complex.get(SCIM_STEP).ok_or_else(|| {
error!("Invalid scim complex attr - missing required key step");
OperationError::InvalidAttribute(format!(
"missing required key step - {scim_attr_name}"
))
}).and_then(|step| {
match step {
ScimAttr::Integer(s) if *s >= 30 => Ok(*s as u64),
_ =>
Err(OperationError::InvalidAttribute(format!(
"step must be a positive integer value equal to or greater than 30 - {scim_attr_name}"
))),
}
})?;
let digits = complex
.get(SCIM_DIGITS)
.ok_or_else(|| {
error!("Invalid scim complex attr - missing required key digits");
OperationError::InvalidAttribute(format!(
"missing required key digits - {scim_attr_name}"
))
})
.and_then(|digits| match digits {
ScimAttr::Integer(6) => Ok(TotpDigits::Six),
ScimAttr::Integer(8) => Ok(TotpDigits::Eight),
_ => {
error!("Invalid digits attribute - must be scim simple integer with the value 6 or 8");
Err(OperationError::InvalidAttribute(format!(
"digits must be a positive integer value of 6 OR 8 - {scim_attr_name}"
)))
}
})?;
let totp = Totp::new(secret, step, algo, digits);
vs.push(Value::TotpSecret(external_id, totp))
}
Ok(vs)
}
(SyntaxType::EmailAddress, true, ScimValue::MultiComplex(values)) => {
let mut vs = Vec::with_capacity(values.len());
for complex in values.iter() {
let mail_addr = complex
.get("value")
.ok_or_else(|| {
error!("Invalid scim complex attr - missing required key value");
OperationError::InvalidAttribute(format!(
"missing required key value - {scim_attr_name}"
))
})
.and_then(|external_id| match external_id {
ScimAttr::String(value) => Ok(value.clone()),
_ => {
error!("Invalid value attribute - must be scim simple string");
Err(OperationError::InvalidAttribute(format!(
"value must be scim simple string - {scim_attr_name}"
)))
}
})?;
let primary = if let Some(primary) = complex.get("primary") {
match primary {
ScimAttr::Bool(value) => Ok(*value),
_ => {
error!("Invalid primary attribute - must be scim simple bool");
Err(OperationError::InvalidAttribute(format!(
"primary must be scim simple bool - {scim_attr_name}"
)))
}
}?
} else {
false
};
vs.push(Value::EmailAddress(mail_addr, primary))
}
Ok(vs)
}
(SyntaxType::SshKey, true, ScimValue::MultiComplex(values)) => {
let mut vs = Vec::with_capacity(values.len());
for complex in values.iter() {
let label = complex
.get("label")
.ok_or_else(|| {
error!("Invalid scim complex attr - missing required key label");
OperationError::InvalidAttribute(format!(
"missing required key label - {scim_attr_name}"
))
})
.and_then(|external_id| match external_id {
ScimAttr::String(value) => Ok(value.clone()),
_ => {
error!("Invalid value attribute - must be scim simple string");
Err(OperationError::InvalidAttribute(format!(
"value must be scim simple string - {scim_attr_name}"
)))
}
})?;
let value = complex
.get("value")
.ok_or_else(|| {
error!("Invalid scim complex attr - missing required key value");
OperationError::InvalidAttribute(format!(
"missing required key value - {scim_attr_name}"
))
})
.and_then(|external_id| match external_id {
ScimAttr::String(value) => SshPublicKey::from_string(value)
.map_err(|err| {
error!(?err, "Invalid ssh key provided via scim");
OperationError::SC0001IncomingSshPublicKey
}),
_ => {
error!("Invalid value attribute - must be scim simple string");
Err(OperationError::InvalidAttribute(format!(
"value must be scim simple string - {scim_attr_name}"
)))
}
})?;
vs.push(Value::SshKey(label, value))
}
Ok(vs)
}
(
SyntaxType::DateTime,
false,
ScimValue::Simple(ScimAttr::String(value)),
) => {
Value::new_datetime_s(value)
.map(|v| vec![v])
.ok_or_else(|| {
error!("Invalid value attribute - must be scim simple string with rfc3339 formatted datetime");
OperationError::InvalidAttribute(format!(
"value must be scim simple string with rfc3339 formatted datetime - {scim_attr_name}"
))
})
}
(syn, mv, sa) => {
error!(?syn, ?mv, ?sa, "Unsupported scim attribute conversion. This may be a syntax error in your import, or a missing feature in Kanidm.");
Err(OperationError::InvalidAttribute(format!(
"Unsupported attribute conversion - {scim_attr_name}"
)))
}
}
}
fn scim_entry_to_mod(
&mut self,
scim_ent: &ScimEntry,
sync_uuid: Uuid,
sync_allow_class_set: &BTreeMap<String, SchemaClass>,
sync_allow_attr_set: &BTreeSet<Attribute>,
phantom_attr_set: &BTreeSet<Attribute>,
) -> Result<ModifyList<ModifyInvalid>, OperationError> {
let requested_classes = scim_ent.schemas.iter()
.map(|schema| {
schema.as_str().strip_prefix(SCIM_SCHEMA_SYNC_1)
.ok_or_else(|| {
error!(?schema, "Invalid requested schema - Not a kanidm sync schema.");
OperationError::InvalidEntryState
})
.and_then(|cls_name| {
sync_allow_class_set.get_key_value(cls_name)
.ok_or_else(|| {
error!(?cls_name, "Invalid requested schema - Class does not exist in Kanidm or is not a sync_allowed class");
OperationError::InvalidEntryState
})
})
})
.collect::<Result<BTreeMap<&String, &SchemaClass>, _>>()?;
debug!("Schemas valid - Proceeding with entry {}", scim_ent.id);
#[allow(clippy::vec_init_then_push)]
let mut mods = Vec::with_capacity(4);
mods.push(Modify::Assert(
Attribute::SyncParentUuid,
PartialValue::Refer(sync_uuid),
));
for req_class in requested_classes.keys() {
mods.push(Modify::Present(
Attribute::SyncClass,
Value::new_iutf8(req_class),
));
mods.push(Modify::Present(
Attribute::Class,
Value::new_iutf8(req_class),
));
}
debug!(?requested_classes);
let sync_owned_attrs: BTreeSet<Attribute> = requested_classes
.values()
.flat_map(|cls| {
cls.systemmay
.iter()
.chain(cls.may.iter())
.chain(cls.systemmust.iter())
.chain(cls.must.iter())
})
.filter(|a| sync_allow_attr_set.contains(*a))
.chain(phantom_attr_set.iter())
.cloned()
.collect();
debug!(?sync_owned_attrs);
for attr in sync_owned_attrs.iter() {
if !phantom_attr_set.contains(attr) {
mods.push(Modify::Purged(attr.clone()));
}
}
for (scim_attr_name, scim_attr) in scim_ent.attrs.iter() {
let scim_attr_name = Attribute::from(scim_attr_name.as_str());
if !sync_owned_attrs.contains(&scim_attr_name) {
error!(
"Rejecting attribute {} for entry {} which is not sync owned",
scim_attr_name, scim_ent.id
);
return Err(OperationError::InvalidEntryState);
}
let values = self
.scim_attr_to_values(&scim_attr_name, scim_attr)
.inspect_err(|err| {
error!(
?err,
"Failed to convert {} for entry {}", scim_attr_name, scim_ent.id
);
})?;
mods.extend(
values
.into_iter()
.map(|val| Modify::Present(scim_attr_name.clone(), val)),
);
}
trace!(?mods);
Ok(ModifyList::new_list(mods))
}
#[instrument(level = "debug", skip_all)]
pub(crate) fn scim_sync_apply_phase_3(
&mut self,
change_entries: &BTreeMap<Uuid, &ScimEntry>,
sync_uuid: Uuid,
sync_authority_set: &BTreeSet<Attribute>,
) -> Result<(), OperationError> {
if change_entries.is_empty() {
info!("No change_entries requested");
return Ok(());
}
let schema = self.qs_write.get_schema();
let class_snapshot = schema.get_classes();
let attr_snapshot = schema.get_attributes();
let sync_allow_class_set: BTreeMap<String, SchemaClass> = class_snapshot
.values()
.filter_map(|cls| {
if cls.sync_allowed {
Some((cls.name.to_string(), cls.clone()))
} else {
None
}
})
.collect();
let sync_allow_attr_set: BTreeSet<Attribute> = attr_snapshot
.values()
.filter_map(|attr| {
if attr.sync_allowed && !sync_authority_set.contains(&attr.name) {
Some(attr.name.clone())
} else {
None
}
})
.collect();
let phantom_attr_set: BTreeSet<Attribute> = attr_snapshot
.values()
.filter_map(|attr| {
if attr.phantom && attr.sync_allowed {
Some(attr.name.clone())
} else {
None
}
})
.collect();
let asserts = change_entries
.iter()
.map(|(u, scim_ent)| {
self.scim_entry_to_mod(
scim_ent,
sync_uuid,
&sync_allow_class_set,
&sync_allow_attr_set,
&phantom_attr_set,
)
.map(|e| (*u, e))
})
.collect::<Result<Vec<_>, _>>()?;
self.qs_write
.internal_batch_modify(asserts.into_iter())
.inspect_err(|err| {
error!(?err, "Unable to apply modifications to sync entries.");
})
}
#[instrument(level = "debug", skip_all)]
pub(crate) fn scim_sync_apply_phase_4(
&mut self,
retain: &ScimSyncRetentionMode,
sync_uuid: Uuid,
) -> Result<(), OperationError> {
let delete_filter = match retain {
ScimSyncRetentionMode::Ignore => {
info!("No retention mode requested");
return Ok(());
}
ScimSyncRetentionMode::Retain(present_uuids) => {
let filter_or = present_uuids
.iter()
.copied()
.map(|u| f_eq(Attribute::Uuid, PartialValue::Uuid(u)))
.collect::<Vec<_>>();
if filter_or.is_empty() {
filter!(f_and!([
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid))
]))
} else {
filter!(f_and!([
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid)),
f_andnot(f_or(filter_or))
]))
}
}
ScimSyncRetentionMode::Delete(delete_uuids) => {
if delete_uuids.is_empty() {
info!("No delete_uuids requested");
return Ok(());
}
let filter_or = delete_uuids
.iter()
.copied()
.map(|u| f_eq(Attribute::Uuid, PartialValue::Uuid(u)))
.collect();
let delete_cands = self
.qs_write
.internal_search(filter_all!(f_or(filter_or)))
.inspect_err(|err| {
error!(?err, "Failed to determine existing entries set");
})?;
let delete_filter = delete_cands
.into_iter()
.filter_map(|ent| {
if ent.mask_recycled_ts().is_none() {
debug!("Skipping already deleted entry {}", ent.get_uuid());
None
} else if ent.get_ava_single_refer(Attribute::SyncParentUuid)
!= Some(sync_uuid)
{
warn!(
"Skipping entry that is not within sync control {}",
ent.get_uuid()
);
Some(Err(OperationError::AccessDenied))
} else {
Some(Ok(f_eq(
Attribute::Uuid,
PartialValue::Uuid(ent.get_uuid()),
)))
}
})
.collect::<Result<Vec<_>, _>>()?;
if delete_filter.is_empty() {
info!("No valid deletes requested");
return Ok(());
}
filter!(f_and(vec![
f_eq(Attribute::SyncParentUuid, PartialValue::Refer(sync_uuid)),
f_or(delete_filter)
]))
}
};
let res = self.qs_write.internal_delete(&delete_filter).map_err(|e| {
error!(?e, "Failed to delete uuids");
e
});
match res {
Ok(()) => Ok(()),
Err(OperationError::NoMatchingEntries) => {
debug!("No deletes required");
Ok(())
}
Err(e) => Err(e),
}
}
#[instrument(level = "debug", skip_all)]
pub(crate) fn scim_sync_apply_phase_5(
&mut self,
sync_uuid: Uuid,
to_state: &ScimSyncState,
) -> Result<(), OperationError> {
let modlist = match to_state {
ScimSyncState::Active { cookie } => ModifyList::new_purge_and_set(
Attribute::SyncCookie,
Value::PrivateBinary(cookie.to_vec()),
),
ScimSyncState::Refresh => ModifyList::new_purge(Attribute::SyncCookie),
};
self.qs_write
.internal_modify_uuid(sync_uuid, &modlist)
.inspect_err(|err| {
error!(?err, "Failed to update sync entry state");
})
}
}
impl<'a> IdmServerProxyReadTransaction<'a> {
pub fn scim_sync_get_state(
&mut self,
ident: &Identity,
) -> Result<ScimSyncState, OperationError> {
let sync_uuid = match &ident.origin {
IdentType::User(_) | IdentType::Internal => {
warn!("Ident type is not synchronise");
return Err(OperationError::AccessDenied);
}
IdentType::Synch(u) => {
*u
}
};
match ident.access_scope() {
AccessScope::ReadOnly | AccessScope::ReadWrite => {
warn!("Ident access scope is not synchronise");
return Err(OperationError::AccessDenied);
}
AccessScope::Synchronise => {
}
};
let sync_entry = self.qs_read.internal_search_uuid(sync_uuid)?;
Ok(
match sync_entry.get_ava_single_private_binary(Attribute::SyncCookie) {
Some(b) => ScimSyncState::Active { cookie: b.to_vec() },
None => ScimSyncState::Refresh,
},
)
}
}
#[cfg(test)]
mod tests {
use crate::idm::server::{IdmServerProxyWriteTransaction, IdmServerTransaction};
use crate::prelude::*;
use compact_jwt::traits::JwsVerifiable;
use compact_jwt::{Jws, JwsCompact, JwsEs256Signer, JwsSigner};
use kanidm_proto::internal::ApiTokenPurpose;
use kanidm_proto::scim_v1::*;
use std::sync::Arc;
use std::time::Duration;
use super::{
GenerateScimSyncTokenEvent, ScimSyncFinaliseEvent, ScimSyncTerminateEvent, ScimSyncToken,
ScimSyncUpdateEvent,
};
const TEST_CURRENT_TIME: u64 = 6000;
fn create_scim_sync_account(
idms_prox_write: &mut IdmServerProxyWriteTransaction<'_>,
ct: Duration,
) -> (Uuid, JwsCompact) {
let sync_uuid = Uuid::new_v4();
let e1 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::SyncAccount.to_value()),
(Attribute::Name, Value::new_iname("test_scim_sync")),
(Attribute::Uuid, Value::Uuid(sync_uuid)),
(
Attribute::Description,
Value::new_utf8s("A test sync agreement")
)
);
idms_prox_write
.qs_write
.internal_create(vec![e1])
.expect("Failed to create sync account");
let gte = GenerateScimSyncTokenEvent::new_internal(sync_uuid, "Sync Connector");
let sync_token = idms_prox_write
.scim_sync_generate_token(>e, ct)
.expect("failed to generate new scim sync token");
(sync_uuid, sync_token)
}
#[idm_test]
async fn test_idm_scim_sync_basic_function(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (sync_uuid, sync_token) = create_scim_sync_account(&mut idms_prox_write, ct);
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_read = idms.proxy_read().await.unwrap();
let ident = idms_prox_read
.validate_sync_client_auth_info_to_ident(sync_token.into(), ct)
.expect("Failed to validate sync token");
assert_eq!(Some(sync_uuid), ident.get_uuid());
let sync_state = idms_prox_read
.scim_sync_get_state(&ident)
.expect("Failed to get current sync state");
trace!(?sync_state);
assert!(matches!(sync_state, ScimSyncState::Refresh));
drop(idms_prox_read);
}
#[idm_test]
async fn test_idm_scim_sync_token_security(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let sync_uuid = Uuid::new_v4();
let e1 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::SyncAccount.to_value()),
(Attribute::Name, Value::new_iname("test_scim_sync")),
(Attribute::Uuid, Value::Uuid(sync_uuid)),
(
Attribute::Description,
Value::new_utf8s("A test sync agreement")
)
);
let ce = CreateEvent::new_internal(vec![e1]);
let cr = idms_prox_write.qs_write.create(&ce);
assert!(cr.is_ok());
let gte = GenerateScimSyncTokenEvent::new_internal(sync_uuid, "Sync Connector");
let sync_token = idms_prox_write
.scim_sync_generate_token(>e, ct)
.expect("failed to generate new scim sync token");
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_read = idms.proxy_read().await.unwrap();
let ident = idms_prox_read
.validate_sync_client_auth_info_to_ident(sync_token.clone().into(), ct)
.expect("Failed to validate sync token");
assert_eq!(Some(sync_uuid), ident.get_uuid());
drop(idms_prox_read);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let me_inv_m = ModifyEvent::new_internal_invalid(
filter!(f_eq(
Attribute::Name,
PartialValue::new_iname("test_scim_sync")
)),
ModifyList::new_list(vec![Modify::Purged(Attribute::SyncTokenSession)]),
);
assert!(idms_prox_write.qs_write.modify(&me_inv_m).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_read = idms.proxy_read().await.unwrap();
let fail =
idms_prox_read.validate_sync_client_auth_info_to_ident(sync_token.clone().into(), ct);
assert!(matches!(fail, Err(OperationError::NotAuthenticated)));
drop(idms_prox_read);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let gte = GenerateScimSyncTokenEvent::new_internal(sync_uuid, "Sync Connector");
let sync_token = idms_prox_write
.scim_sync_generate_token(>e, ct)
.expect("failed to generate new scim sync token");
let revoke_kid = sync_token.kid().expect("token does not contain a key id");
idms_prox_write
.qs_write
.internal_modify_uuid(
UUID_DOMAIN_INFO,
&ModifyList::new_append(
Attribute::KeyActionRevoke,
Value::HexString(revoke_kid.to_string()),
),
)
.expect("Unable to revoke key");
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_read = idms.proxy_read().await.unwrap();
let fail =
idms_prox_read.validate_sync_client_auth_info_to_ident(sync_token.clone().into(), ct);
assert!(matches!(fail, Err(OperationError::NotAuthenticated)));
let sync_entry = idms_prox_read
.qs_read
.internal_search_uuid(sync_uuid)
.expect("Unable to access sync entry");
let sync_tokens = sync_entry
.get_ava_as_apitoken_map(Attribute::SyncTokenSession)
.cloned()
.unwrap_or_default();
let (token_id, issued_at) = sync_tokens
.iter()
.next()
.map(|(k, v)| (*k, v.issued_at))
.expect("No sync tokens present");
let purpose = ApiTokenPurpose::ReadWrite;
let scim_sync_token = ScimSyncToken {
token_id,
issued_at,
purpose,
};
let token = Jws::into_json(&scim_sync_token).expect("Unable to serialise forged token");
let jws_key = JwsEs256Signer::generate_es256().expect("Unable to create signer");
let forged_token = jws_key.sign(&token).expect("Unable to sign forged token");
let fail = idms_prox_read.validate_sync_client_auth_info_to_ident(forged_token.into(), ct);
assert!(matches!(fail, Err(OperationError::NotAuthenticated)));
}
fn test_scim_sync_apply_setup_ident(
idms_prox_write: &mut IdmServerProxyWriteTransaction,
ct: Duration,
) -> (Uuid, Identity) {
let sync_uuid = Uuid::new_v4();
let e1 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::SyncAccount.to_value()),
(Attribute::Name, Value::new_iname("test_scim_sync")),
(Attribute::Uuid, Value::Uuid(sync_uuid)),
(
Attribute::Description,
Value::new_utf8s("A test sync agreement")
)
);
let ce = CreateEvent::new_internal(vec![e1]);
let cr = idms_prox_write.qs_write.create(&ce);
assert!(cr.is_ok());
let gte = GenerateScimSyncTokenEvent::new_internal(sync_uuid, "Sync Connector");
let sync_token = idms_prox_write
.scim_sync_generate_token(>e, ct)
.expect("failed to generate new scim sync token");
let ident = idms_prox_write
.validate_sync_client_auth_info_to_ident(sync_token.into(), ct)
.expect("Failed to process sync token to ident");
(sync_uuid, ident)
}
#[idm_test]
async fn test_idm_scim_sync_apply_phase_1_inconsistent(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
to_state: ScimSyncState::Refresh,
entries: Vec::with_capacity(0),
retain: ScimSyncRetentionMode::Ignore,
};
let res = idms_prox_write.scim_sync_apply_phase_1(&sse, &changes);
assert!(matches!(res, Err(OperationError::InvalidSyncState)));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_apply_phase_2_basic(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let user_sync_uuid = uuid::uuid!("91b7aaf2-2445-46ce-8998-96d9f186cc69");
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_PERSON.to_string()],
id: user_sync_uuid,
external_id: Some("dn=william,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("william".to_string()))
),),
}],
retain: ScimSyncRetentionMode::Ignore,
};
let (sync_uuid, _sync_authority_set, change_entries, _sync_refresh) = idms_prox_write
.scim_sync_apply_phase_1(&sse, &changes)
.expect("Failed to run phase 1");
idms_prox_write
.scim_sync_apply_phase_2(&change_entries, sync_uuid)
.expect("Failed to run phase 2");
let synced_entry = idms_prox_write
.qs_write
.internal_search_uuid(user_sync_uuid)
.expect("Failed to access sync stub entry");
assert!(
synced_entry.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("dn=william,ou=people,dc=test")
);
assert_eq!(synced_entry.get_uuid(), user_sync_uuid);
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_apply_phase_2_deny_on_tombstone(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let user_sync_uuid = Uuid::new_v4();
assert!(idms_prox_write
.qs_write
.internal_create(vec![entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Uuid, Value::Uuid(user_sync_uuid))
)])
.is_ok());
assert!(idms_prox_write
.qs_write
.internal_delete_uuid(user_sync_uuid)
.is_ok());
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_PERSON.to_string()],
id: user_sync_uuid,
external_id: Some("dn=william,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("william".to_string()))
),),
}],
retain: ScimSyncRetentionMode::Ignore,
};
let (sync_uuid, _sync_authority_set, change_entries, _sync_refresh) = idms_prox_write
.scim_sync_apply_phase_1(&sse, &changes)
.expect("Failed to run phase 1");
let res = idms_prox_write.scim_sync_apply_phase_2(&change_entries, sync_uuid);
assert!(matches!(res, Err(OperationError::InvalidEntryState)));
assert!(idms_prox_write.commit().is_ok());
}
async fn apply_phase_3_test(
idms: &IdmServer,
entries: Vec<ScimEntry>,
) -> Result<(), OperationError> {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries,
retain: ScimSyncRetentionMode::Ignore,
};
let (sync_uuid, sync_authority_set, change_entries, _sync_refresh) = idms_prox_write
.scim_sync_apply_phase_1(&sse, &changes)
.expect("Failed to run phase 1");
assert!(idms_prox_write
.scim_sync_apply_phase_2(&change_entries, sync_uuid)
.is_ok());
idms_prox_write
.scim_sync_apply_phase_3(&change_entries, sync_uuid, &sync_authority_set)
.and_then(|a| idms_prox_write.commit().map(|()| a))
}
#[idm_test]
async fn test_idm_scim_sync_phase_3_basic(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let user_sync_uuid = Uuid::new_v4();
assert!(apply_phase_3_test(
idms,
vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: user_sync_uuid,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),),
}]
)
.await
.is_ok());
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let ent = idms_prox_write
.qs_write
.internal_search_uuid(user_sync_uuid)
.expect("Unable to access entry");
assert_eq!(ent.get_ava_single_iname(Attribute::Name), Some("testgroup"));
assert!(
ent.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testgroup,ou=people,dc=test")
);
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_3_uuid_manipulation(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let user_sync_uuid = Uuid::new_v4();
assert!(apply_phase_3_test(
idms,
vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: user_sync_uuid,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!(
(
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),
(
Attribute::Uuid.to_string(),
ScimValue::Simple(ScimAttr::String(
"2c019619-f894-4a94-b356-05d371850e3d".to_string()
))
)
),
}]
)
.await
.is_err());
}
#[idm_test]
async fn test_idm_scim_sync_phase_3_sync_parent_uuid_manipulation(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let user_sync_uuid = Uuid::new_v4();
assert!(apply_phase_3_test(
idms,
vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: user_sync_uuid,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!(
(
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),
(
"sync_parent_uuid".to_string(),
ScimValue::Simple(ScimAttr::String(
"2c019619-f894-4a94-b356-05d371850e3d".to_string()
))
)
),
}]
)
.await
.is_err());
}
#[idm_test]
async fn test_idm_scim_sync_phase_3_disallowed_class_forbidden(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let user_sync_uuid = Uuid::new_v4();
assert!(apply_phase_3_test(
idms,
vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: user_sync_uuid,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!(
(
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),
(
Attribute::Class.to_string(),
ScimValue::Simple(ScimAttr::String("posixgroup".to_string()))
)
),
}]
)
.await
.is_err());
}
#[idm_test]
async fn test_idm_scim_sync_phase_3_disallowed_class_system(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let user_sync_uuid = Uuid::new_v4();
assert!(apply_phase_3_test(
idms,
vec![ScimEntry {
schemas: vec![format!("{SCIM_SCHEMA_SYNC_1}system")],
id: user_sync_uuid,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),),
}]
)
.await
.is_err());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_correct_delete(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let user_sync_uuid = Uuid::new_v4();
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent {
ident: ident.clone(),
};
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: user_sync_uuid,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),),
}],
retain: ScimSyncRetentionMode::Ignore,
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
to_state: ScimSyncState::Active {
cookie: vec![2, 3, 4, 5],
},
entries: vec![],
retain: ScimSyncRetentionMode::Delete(vec![user_sync_uuid]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write
.qs_write
.internal_search(filter_all!(f_eq(
Attribute::Uuid,
PartialValue::Uuid(user_sync_uuid)
)))
.map(|entries| {
assert_eq!(entries.len(), 1);
let ent = entries.first().unwrap();
ent.mask_recycled_ts().is_none()
})
.unwrap_or(false));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_nonexisting_delete(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: Vec::with_capacity(0),
retain: ScimSyncRetentionMode::Delete(vec![Uuid::new_v4()]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_out_of_scope_delete(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let user_sync_uuid = Uuid::new_v4();
assert!(idms_prox_write
.qs_write
.internal_create(vec![entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Uuid, Value::Uuid(user_sync_uuid))
)])
.is_ok());
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: Vec::with_capacity(0),
retain: ScimSyncRetentionMode::Delete(vec![user_sync_uuid]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_err());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_delete_already_deleted(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let user_sync_uuid = Uuid::new_v4();
assert!(idms_prox_write
.qs_write
.internal_create(vec![entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Uuid, Value::Uuid(user_sync_uuid))
)])
.is_ok());
assert!(idms_prox_write
.qs_write
.internal_delete_uuid(user_sync_uuid)
.is_ok());
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: Vec::with_capacity(0),
retain: ScimSyncRetentionMode::Delete(vec![user_sync_uuid]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_correct_retain(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let sync_uuid_a = Uuid::new_v4();
let sync_uuid_b = Uuid::new_v4();
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent {
ident: ident.clone(),
};
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: vec![
ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: sync_uuid_a,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),),
},
ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: sync_uuid_b,
external_id: Some("cn=anothergroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("anothergroup".to_string()))
),),
},
],
retain: ScimSyncRetentionMode::Ignore,
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
to_state: ScimSyncState::Active {
cookie: vec![2, 3, 4, 5],
},
entries: vec![],
retain: ScimSyncRetentionMode::Retain(vec![sync_uuid_a]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write
.qs_write
.internal_search(filter_all!(f_eq(
Attribute::Uuid,
PartialValue::Uuid(sync_uuid_b)
)))
.map(|entries| {
assert_eq!(entries.len(), 1);
let ent = entries.first().unwrap();
ent.mask_recycled_ts().is_none()
})
.unwrap_or(false));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_retain_none(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let sync_uuid_a = Uuid::new_v4();
let sync_uuid_b = Uuid::new_v4();
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent {
ident: ident.clone(),
};
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: vec![
ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: sync_uuid_a,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("testgroup".to_string()))
),),
},
ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: sync_uuid_b,
external_id: Some("cn=anothergroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimValue::Simple(ScimAttr::String("anothergroup".to_string()))
),),
},
],
retain: ScimSyncRetentionMode::Ignore,
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
to_state: ScimSyncState::Active {
cookie: vec![2, 3, 4, 5],
},
entries: vec![],
retain: ScimSyncRetentionMode::Retain(vec![]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write
.qs_write
.internal_search(filter_all!(f_eq(
Attribute::Uuid,
PartialValue::Uuid(sync_uuid_a)
)))
.map(|entries| {
assert_eq!(entries.len(), 1);
let ent = entries.first().unwrap();
ent.mask_recycled_ts().is_none()
})
.unwrap_or(false));
assert!(idms_prox_write
.qs_write
.internal_search(filter_all!(f_eq(
Attribute::Uuid,
PartialValue::Uuid(sync_uuid_b)
)))
.map(|entries| {
assert_eq!(entries.len(), 1);
let ent = entries.first().unwrap();
ent.mask_recycled_ts().is_none()
})
.unwrap_or(false));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_4_retain_no_deletes(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let sync_uuid_a = Uuid::new_v4();
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent {
ident: ident.clone(),
};
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: vec![ScimEntry {
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
id: sync_uuid_a,
external_id: Some("cn=testgroup,ou=people,dc=test".to_string()),
meta: None,
attrs: btreemap!((
Attribute::Name.to_string(),
ScimAttr::String("testgroup".to_string()).into()
),),
}],
retain: ScimSyncRetentionMode::Ignore,
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
to_state: ScimSyncState::Active {
cookie: vec![2, 3, 4, 5],
},
entries: vec![],
retain: ScimSyncRetentionMode::Retain(vec![sync_uuid_a]),
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
let ent = idms_prox_write
.qs_write
.internal_search_uuid(sync_uuid_a)
.expect("Unable to access entry");
assert_eq!(ent.get_ava_single_iname(Attribute::Name), Some("testgroup"));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_phase_5_from_refresh_to_active(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent {
ident: ident.clone(),
};
let changes = ScimSyncRequest {
from_state: ScimSyncState::Refresh,
to_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
entries: Vec::with_capacity(0),
retain: ScimSyncRetentionMode::Ignore,
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let sse = ScimSyncUpdateEvent { ident };
let changes = ScimSyncRequest {
from_state: ScimSyncState::Active {
cookie: vec![1, 2, 3, 4],
},
to_state: ScimSyncState::Active {
cookie: vec![2, 3, 4, 5],
},
entries: vec![],
retain: ScimSyncRetentionMode::Ignore,
};
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
}
fn get_single_entry(
name: &str,
idms_prox_write: &mut IdmServerProxyWriteTransaction,
) -> Arc<EntrySealedCommitted> {
idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname(name)
)))
.map_err(|_| ())
.and_then(|mut entries| {
if entries.len() != 1 {
error!("Incorrect number of results {:?}", entries);
Err(())
} else {
entries.pop().ok_or(())
}
})
.expect("Failed to access entry.")
}
#[idm_test]
async fn test_idm_scim_sync_refresh_ipa_example_1(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let testgroup = get_single_entry("testgroup", &mut idms_prox_write);
assert!(
testgroup.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testgroup,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert!(testgroup
.get_ava_single_uint32(Attribute::GidNumber)
.is_none());
let testposix = get_single_entry("testposix", &mut idms_prox_write);
assert!(
testposix.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testposix,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert_eq!(
testposix.get_ava_single_uint32(Attribute::GidNumber),
Some(1234567)
);
let testexternal = get_single_entry("testexternal", &mut idms_prox_write);
assert!(
testexternal.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testexternal,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert!(testexternal
.get_ava_single_uint32(Attribute::GidNumber)
.is_none());
let testuser = get_single_entry("testuser", &mut idms_prox_write);
assert!(
testuser.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert_eq!(
testuser.get_ava_single_uint32(Attribute::GidNumber),
Some(12345)
);
assert_eq!(
testuser.get_ava_single_utf8(Attribute::DisplayName),
Some("Test User")
);
assert_eq!(
testuser.get_ava_single_iutf8(Attribute::LoginShell),
Some("/bin/sh")
);
let mut ssh_keyiter = testuser
.get_ava_iter_sshpubkeys(Attribute::SshPublicKey)
.expect("Failed to access ssh pubkeys");
assert_eq!(ssh_keyiter.next(), Some("sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBENubZikrb8hu+HeVRdZ0pp/VAk2qv4JDbuJhvD0yNdWDL2e3cBbERiDeNPkWx58Q4rVnxkbV1fa8E2waRtT91wAAAAEc3NoOg== testuser@fidokey".to_string()));
assert_eq!(ssh_keyiter.next(), None);
let testgroup_mb = testgroup
.get_ava_refer(Attribute::Member)
.expect("No members!");
assert!(testgroup_mb.contains(&testuser.get_uuid()));
let testposix_mb = testposix
.get_ava_refer(Attribute::Member)
.expect("No members!");
assert!(testposix_mb.contains(&testuser.get_uuid()));
let testuser_mo = testuser
.get_ava_refer(Attribute::MemberOf)
.expect("No memberof!");
assert!(testuser_mo.contains(&testposix.get_uuid()));
assert!(testuser_mo.contains(&testgroup.get_uuid()));
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_2).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
assert!(idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname("testgroup")
)))
.unwrap()
.is_empty());
let testposix = get_single_entry("testposix", &mut idms_prox_write);
info!("{:?}", testposix);
assert!(
testposix.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testposix,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert_eq!(
testposix.get_ava_single_uint32(Attribute::GidNumber),
Some(1234567)
);
let testexternal = get_single_entry("testexternal2", &mut idms_prox_write);
info!("{:?}", testexternal);
assert!(
testexternal.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testexternal2,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert!(testexternal
.get_ava_single_uint32(Attribute::GidNumber)
.is_none());
let testuser = get_single_entry("testuser", &mut idms_prox_write);
let testexternal_mb = testexternal
.get_ava_refer(Attribute::Member)
.expect("No members!");
assert!(testexternal_mb.contains(&testuser.get_uuid()));
assert!(testposix.get_ava_refer(Attribute::Member).is_none());
let testuser_mo = testuser
.get_ava_refer(Attribute::MemberOf)
.expect("No memberof!");
assert!(testuser_mo.contains(&testexternal.get_uuid()));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_refresh_ipa_example_2(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
let from_state = changes.to_state.clone();
let changes = ScimSyncRequest::need_refresh(from_state);
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
let testgroup = get_single_entry("testgroup", &mut idms_prox_write);
assert!(
testgroup.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testgroup,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert!(testgroup
.get_ava_single_uint32(Attribute::GidNumber)
.is_none());
let testposix = get_single_entry("testposix", &mut idms_prox_write);
assert!(
testposix.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testposix,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert_eq!(
testposix.get_ava_single_uint32(Attribute::GidNumber),
Some(1234567)
);
let testexternal = get_single_entry("testexternal", &mut idms_prox_write);
assert!(
testexternal.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testexternal,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert!(testexternal
.get_ava_single_uint32(Attribute::GidNumber)
.is_none());
let testuser = get_single_entry("testuser", &mut idms_prox_write);
assert!(
testuser.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert_eq!(
testuser.get_ava_single_uint32(Attribute::GidNumber),
Some(12345)
);
assert_eq!(
testuser.get_ava_single_utf8(Attribute::DisplayName),
Some("Test User")
);
assert_eq!(
testuser.get_ava_single_iutf8(Attribute::LoginShell),
Some("/bin/sh")
);
let testgroup_mb = testgroup
.get_ava_refer(Attribute::Member)
.expect("No members!");
assert!(testgroup_mb.contains(&testuser.get_uuid()));
let testposix_mb = testposix
.get_ava_refer(Attribute::Member)
.expect("No members!");
assert!(testposix_mb.contains(&testuser.get_uuid()));
let testuser_mo = testuser
.get_ava_refer(Attribute::MemberOf)
.expect("No memberof!");
assert!(testuser_mo.contains(&testposix.get_uuid()));
assert!(testuser_mo.contains(&testgroup.get_uuid()));
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_REFRESH_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname("testposix")
)))
.unwrap()
.is_empty());
assert!(idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname("testexternal")
)))
.unwrap()
.is_empty());
let testgroup = get_single_entry("testgroup", &mut idms_prox_write);
assert!(
testgroup.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("cn=testgroup,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert!(testgroup
.get_ava_single_uint32(Attribute::GidNumber)
.is_none());
let testuser = get_single_entry("testuser", &mut idms_prox_write);
assert!(
testuser.get_ava_single_iutf8(Attribute::SyncExternalId)
== Some("uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au")
);
assert_eq!(
testuser.get_ava_single_uint32(Attribute::GidNumber),
Some(12345)
);
assert_eq!(
testuser.get_ava_single_utf8(Attribute::DisplayName),
Some("Test User")
);
assert_eq!(
testuser.get_ava_single_iutf8(Attribute::LoginShell),
Some("/bin/sh")
);
let testgroup_mb = testgroup
.get_ava_refer(Attribute::Member)
.expect("No members!");
assert!(testgroup_mb.contains(&testuser.get_uuid()));
let testuser_mo = testuser
.get_ava_refer(Attribute::MemberOf)
.expect("No memberof!");
assert!(testuser_mo.contains(&testgroup.get_uuid()));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_yield_authority(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write
.qs_write
.internal_modify_uuid(
sync_uuid,
&ModifyList::new_purge_and_set(
Attribute::SyncYieldAuthority,
Value::new_iutf8(Attribute::LegalName.as_ref())
)
)
.is_ok());
let testuser_filter = filter!(f_eq(Attribute::Name, PartialValue::new_iname("testuser")));
assert!(idms_prox_write
.qs_write
.internal_modify(
&testuser_filter,
&ModifyList::new_purge_and_set(
Attribute::LegalName,
Value::Utf8("Test Userington the First".to_string())
)
)
.is_ok());
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_REFRESH_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
let testuser = idms_prox_write
.qs_write
.internal_search(testuser_filter)
.map(|mut results| results.pop().expect("Empty result set"))
.expect("Failed to access testuser");
assert!(
testuser.get_ava_single_utf8(Attribute::LegalName) == Some("Test Userington the First")
);
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_finalise_1(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let ident = idms_prox_write
.qs_write
.internal_search_uuid(UUID_ADMIN)
.map(Identity::from_impersonate_entry_readwrite)
.expect("Failed to get admin");
let sfe = ScimSyncFinaliseEvent {
ident,
target: sync_uuid,
};
idms_prox_write
.scim_sync_finalise(&sfe)
.expect("Failed to finalise sync account");
let testgroup = get_single_entry("testgroup", &mut idms_prox_write);
assert!(!testgroup.attribute_equality(Attribute::Class, &EntryClass::SyncObject.into()));
let testposix = get_single_entry("testposix", &mut idms_prox_write);
assert!(!testposix.attribute_equality(Attribute::Class, &EntryClass::SyncObject.into()));
let testexternal = get_single_entry("testexternal", &mut idms_prox_write);
assert!(!testexternal.attribute_equality(Attribute::Class, &EntryClass::SyncObject.into()));
let testuser = get_single_entry("testuser", &mut idms_prox_write);
assert!(!testuser.attribute_equality(Attribute::Class, &EntryClass::SyncObject.into()));
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_finalise_2(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_REFRESH_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let ident = idms_prox_write
.qs_write
.internal_search_uuid(UUID_ADMIN)
.map(Identity::from_impersonate_entry_readwrite)
.expect("Failed to get admin");
let sfe = ScimSyncFinaliseEvent {
ident,
target: sync_uuid,
};
idms_prox_write
.scim_sync_finalise(&sfe)
.expect("Failed to finalise sync account");
let testgroup = get_single_entry("testgroup", &mut idms_prox_write);
assert!(!testgroup.attribute_equality(Attribute::Class, &EntryClass::SyncObject.into()));
let testuser = get_single_entry("testuser", &mut idms_prox_write);
assert!(!testuser.attribute_equality(Attribute::Class, &EntryClass::SyncObject.into()));
for iname in ["testposix", "testexternal"] {
trace!(%iname);
assert!(idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname(iname)
)))
.unwrap()
.is_empty());
}
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_terminate_1(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let ident = idms_prox_write
.qs_write
.internal_search_uuid(UUID_ADMIN)
.map(Identity::from_impersonate_entry_readwrite)
.expect("Failed to get admin");
let sfe = ScimSyncTerminateEvent {
ident,
target: sync_uuid,
};
idms_prox_write
.scim_sync_terminate(&sfe)
.expect("Failed to terminate sync account");
for iname in ["testgroup", "testposix", "testexternal", "testuser"] {
trace!(%iname);
assert!(idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname(iname)
)))
.unwrap()
.is_empty());
}
assert!(idms_prox_write.commit().is_ok());
}
#[idm_test]
async fn test_idm_scim_sync_terminate_2(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let (sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
let sse = ScimSyncUpdateEvent { ident };
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
let changes =
serde_json::from_str(TEST_SYNC_SCIM_IPA_REFRESH_1).expect("failed to parse scim sync");
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
assert!(idms_prox_write.commit().is_ok());
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
let ident = idms_prox_write
.qs_write
.internal_search_uuid(UUID_ADMIN)
.map(Identity::from_impersonate_entry_readwrite)
.expect("Failed to get admin");
let sfe = ScimSyncTerminateEvent {
ident,
target: sync_uuid,
};
idms_prox_write
.scim_sync_terminate(&sfe)
.expect("Failed to terminate sync account");
for iname in ["testgroup", "testposix", "testexternal", "testuser"] {
trace!(%iname);
assert!(idms_prox_write
.qs_write
.internal_search(filter!(f_eq(
Attribute::Name,
PartialValue::new_iname(iname)
)))
.unwrap()
.is_empty());
}
assert!(idms_prox_write.commit().is_ok());
}
const TEST_SYNC_SCIM_IPA_1: &str = r#"
{
"from_state": "Refresh",
"to_state": {
"Active": {
"cookie": "aXBhLXN5bmNyZXBsLWthbmkuZGV2LmJsYWNraGF0cy5uZXQuYXU6Mzg5I2NuPWRpcmVjdG9yeSBtYW5hZ2VyOmRjPWRldixkYz1ibGFja2hhdHMsZGM9bmV0LGRjPWF1Oih8KCYob2JqZWN0Q2xhc3M9cGVyc29uKShvYmplY3RDbGFzcz1pcGFudHVzZXJhdHRycykob2JqZWN0Q2xhc3M9cG9zaXhhY2NvdW50KSkoJihvYmplY3RDbGFzcz1ncm91cG9mbmFtZXMpKG9iamVjdENsYXNzPWlwYXVzZXJncm91cCkoIShvYmplY3RDbGFzcz1tZXBtYW5hZ2VkZW50cnkpKSghKGNuPWFkbWlucykpKCEoY249aXBhdXNlcnMpKSkoJihvYmplY3RDbGFzcz1pcGF0b2tlbikob2JqZWN0Q2xhc3M9aXBhdG9rZW50b3RwKSkpIzEzNQ"
}
},
"entries": [
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:person",
"urn:ietf:params:scim:schemas:kanidm:sync:1:account",
"urn:ietf:params:scim:schemas:kanidm:sync:1:posixaccount"
],
"id": "babb8302-43a1-11ed-a50d-919b4b1a5ec0",
"externalId": "uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"displayname": "Test User",
"gidnumber": 12345,
"loginshell": "/bin/sh",
"name": "testuser",
"mail": [
{
"value": "testuser@dev.blackhats.net.au"
}
],
"ssh_publickey": [
{
"label": "ssh-key",
"value": "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBENubZikrb8hu+HeVRdZ0pp/VAk2qv4JDbuJhvD0yNdWDL2e3cBbERiDeNPkWx58Q4rVnxkbV1fa8E2waRtT91wAAAAEc3NoOg== testuser@fidokey"
}
],
"unix_password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA",
"password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA"
},
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:group"
],
"id": "d547c581-5f26-11ed-a50d-919b4b1a5ec0",
"externalId": "cn=testgroup,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"description": "Test group",
"member": [
{
"external_id": "uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au"
}
],
"name": "testgroup"
},
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:group"
],
"id": "d547c583-5f26-11ed-a50d-919b4b1a5ec0",
"externalId": "cn=testexternal,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"name": "testexternal"
},
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:group",
"urn:ietf:params:scim:schemas:kanidm:sync:1:posixgroup"
],
"id": "f90b0b81-5f26-11ed-a50d-919b4b1a5ec0",
"externalId": "cn=testposix,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"gidnumber": 1234567,
"member": [
{
"external_id": "uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au"
}
],
"name": "testposix"
}
],
"retain": "Ignore"
}
"#;
const TEST_SYNC_SCIM_IPA_2: &str = r#"
{
"from_state": {
"Active": {
"cookie": "aXBhLXN5bmNyZXBsLWthbmkuZGV2LmJsYWNraGF0cy5uZXQuYXU6Mzg5I2NuPWRpcmVjdG9yeSBtYW5hZ2VyOmRjPWRldixkYz1ibGFja2hhdHMsZGM9bmV0LGRjPWF1Oih8KCYob2JqZWN0Q2xhc3M9cGVyc29uKShvYmplY3RDbGFzcz1pcGFudHVzZXJhdHRycykob2JqZWN0Q2xhc3M9cG9zaXhhY2NvdW50KSkoJihvYmplY3RDbGFzcz1ncm91cG9mbmFtZXMpKG9iamVjdENsYXNzPWlwYXVzZXJncm91cCkoIShvYmplY3RDbGFzcz1tZXBtYW5hZ2VkZW50cnkpKSghKGNuPWFkbWlucykpKCEoY249aXBhdXNlcnMpKSkoJihvYmplY3RDbGFzcz1pcGF0b2tlbikob2JqZWN0Q2xhc3M9aXBhdG9rZW50b3RwKSkpIzEzNQ"
}
},
"to_state": {
"Active": {
"cookie": "aXBhLXN5bmNyZXBsLWthbmkuZGV2LmJsYWNraGF0cy5uZXQuYXU6Mzg5I2NuPWRpcmVjdG9yeSBtYW5hZ2VyOmRjPWRldixkYz1ibGFja2hhdHMsZGM9bmV0LGRjPWF1Oih8KCYob2JqZWN0Q2xhc3M9cGVyc29uKShvYmplY3RDbGFzcz1pcGFudHVzZXJhdHRycykob2JqZWN0Q2xhc3M9cG9zaXhhY2NvdW50KSkoJihvYmplY3RDbGFzcz1ncm91cG9mbmFtZXMpKG9iamVjdENsYXNzPWlwYXVzZXJncm91cCkoIShvYmplY3RDbGFzcz1tZXBtYW5hZ2VkZW50cnkpKSghKGNuPWFkbWlucykpKCEoY249aXBhdXNlcnMpKSkoJihvYmplY3RDbGFzcz1pcGF0b2tlbikob2JqZWN0Q2xhc3M9aXBhdG9rZW50b3RwKSkpIzE0MA"
}
},
"entries": [
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:group"
],
"id": "d547c583-5f26-11ed-a50d-919b4b1a5ec0",
"externalId": "cn=testexternal2,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"member": [
{
"external_id": "uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au"
}
],
"name": "testexternal2"
},
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:group",
"urn:ietf:params:scim:schemas:kanidm:sync:1:posixgroup"
],
"id": "f90b0b81-5f26-11ed-a50d-919b4b1a5ec0",
"externalId": "cn=testposix,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"gidnumber": 1234567,
"name": "testposix"
}
],
"retain": {
"Delete": [
"d547c581-5f26-11ed-a50d-919b4b1a5ec0"
]
}
}
"#;
const TEST_SYNC_SCIM_IPA_REFRESH_1: &str = r#"
{
"from_state": "Refresh",
"to_state": {
"Active": {
"cookie": "aXBhLXN5bmNyZXBsLWthbmkuZGV2LmJsYWNraGF0cy5uZXQuYXU6Mzg5I2NuPWRpcmVjdG9yeSBtYW5hZ2VyOmRjPWRldixkYz1ibGFja2hhdHMsZGM9bmV0LGRjPWF1Oih8KCYob2JqZWN0Q2xhc3M9cGVyc29uKShvYmplY3RDbGFzcz1pcGFudHVzZXJhdHRycykob2JqZWN0Q2xhc3M9cG9zaXhhY2NvdW50KSkoJihvYmplY3RDbGFzcz1ncm91cG9mbmFtZXMpKG9iamVjdENsYXNzPWlwYXVzZXJncm91cCkoIShvYmplY3RDbGFzcz1tZXBtYW5hZ2VkZW50cnkpKSghKGNuPWFkbWlucykpKCEoY249aXBhdXNlcnMpKSkoJihvYmplY3RDbGFzcz1pcGF0b2tlbikob2JqZWN0Q2xhc3M9aXBhdG9rZW50b3RwKSkpIzEzNQ"
}
},
"entries": [
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:person",
"urn:ietf:params:scim:schemas:kanidm:sync:1:account",
"urn:ietf:params:scim:schemas:kanidm:sync:1:posixaccount"
],
"id": "babb8302-43a1-11ed-a50d-919b4b1a5ec0",
"externalId": "uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"displayname": "Test User",
"gidnumber": 12345,
"loginshell": "/bin/sh",
"name": "testuser",
"password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA",
"unix_password_import": "ipaNTHash: iEb36u6PsRetBr3YMLdYbA",
"account_valid_from": "2021-11-28T04:57:55Z",
"account_expire": "2023-11-28T04:57:55Z"
},
{
"schemas": [
"urn:ietf:params:scim:schemas:kanidm:sync:1:group"
],
"id": "d547c581-5f26-11ed-a50d-919b4b1a5ec0",
"externalId": "cn=testgroup,cn=groups,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au",
"description": "Test group",
"member": [
{
"external_id": "uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au"
}
],
"name": "testgroup"
}
],
"retain": "Ignore"
}
"#;
}