kanidm/server/lib/src/server/scim.rs
2025-04-26 12:48:29 +10:00

595 lines
22 KiB
Rust

use crate::prelude::*;
use crate::schema::{SchemaAttribute, SchemaTransaction};
use crate::server::batch_modify::{BatchModifyEvent, ModSetValid};
use crate::server::ValueSetResolveStatus;
use crate::valueset::*;
use kanidm_proto::scim_v1::client::{ScimEntryPostGeneric, ScimEntryPutGeneric};
use kanidm_proto::scim_v1::JsonValue;
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct ScimEntryPutEvent {
/// The identity performing the change.
pub(crate) ident: Identity,
// future - etags to detect version changes.
/// The target entry that will be changed
pub(crate) target: Uuid,
/// Update an attribute to contain the following value state.
/// If the attribute is None, it is removed.
pub(crate) attrs: BTreeMap<Attribute, Option<ValueSet>>,
/// If an effective access check should be carried out post modification
/// of the entries
pub(crate) effective_access_check: bool,
}
impl ScimEntryPutEvent {
pub fn try_from(
ident: Identity,
entry: ScimEntryPutGeneric,
qs: &mut QueryServerWriteTransaction,
) -> Result<Self, OperationError> {
let target = entry.id;
let attrs = entry
.attrs
.into_iter()
.map(|(attr, json_value)| {
qs.resolve_scim_json_put(&attr, json_value)
.map(|kani_value| (attr, kani_value))
})
.collect::<Result<_, _>>()?;
let query = entry.query;
Ok(ScimEntryPutEvent {
ident,
target,
attrs,
effective_access_check: query.ext_access_check,
})
}
}
#[derive(Debug)]
pub struct ScimCreateEvent {
pub(crate) ident: Identity,
pub(crate) entry: EntryInitNew,
}
impl ScimCreateEvent {
pub fn try_from(
ident: Identity,
classes: &[EntryClass],
entry: ScimEntryPostGeneric,
qs: &mut QueryServerWriteTransaction,
) -> Result<Self, OperationError> {
let entry = entry
.attrs
.into_iter()
.map(|(attr, json_value)| {
qs.resolve_scim_json_post(&attr, json_value)
.map(|kani_value| (attr, kani_value))
})
.collect::<Result<_, _>>()?;
Ok(ScimCreateEvent { ident, entry })
}
}
#[derive(Debug)]
pub struct ScimDeleteEvent {
/// The identity performing the change.
pub(crate) ident: Identity,
// future - etags to detect version changes.
/// The target entry that will be changed
pub(crate) target: Uuid,
/// The class of the target entry.
pub(crate) class: EntryClass,
}
impl ScimDeleteEvent {
pub fn new(ident: Identity, target: Uuid, class: EntryClass) -> Self {
ScimDeleteEvent {
ident,
target,
class,
}
}
}
impl QueryServerWriteTransaction<'_> {
/// SCIM PUT is the handler where a single entry is updated. In a SCIM PUT request
/// the request defines the state of an attribute in entirety for the update. This
/// means if the caller wants to add one email address, they must PUT all existing
/// addresses in addition to the new one.
pub fn scim_put(
&mut self,
scim_entry_put: ScimEntryPutEvent,
) -> Result<ScimEntryKanidm, OperationError> {
let ScimEntryPutEvent {
ident,
target,
attrs,
effective_access_check,
} = scim_entry_put;
// This function transforms the put event into a modify event.
let mods_invalid: ModifyList<ModifyInvalid> = attrs.into();
let mods_valid = mods_invalid
.validate(self.get_schema())
.map_err(OperationError::SchemaViolation)?;
let mut modset = ModSetValid::default();
modset.insert(target, mods_valid);
let modify_event = BatchModifyEvent {
ident: ident.clone(),
modset,
};
// dispatch to batch modify
self.batch_modify(&modify_event)?;
// Now get the entry. We handle a lot of the errors here nicely,
// but if we got to this point, they really can't happen.
let filter_intent = filter!(f_and!([f_eq(Attribute::Uuid, PartialValue::Uuid(target))]));
let f_intent_valid = filter_intent
.validate(self.get_schema())
.map_err(OperationError::SchemaViolation)?;
let f_valid = f_intent_valid.clone().into_ignore_hidden();
let se = SearchEvent {
ident,
filter: f_valid,
filter_orig: f_intent_valid,
// Return all attributes, even ones we didn't affect
attrs: None,
effective_access_check,
};
let mut vs = self.search_ext(&se)?;
match vs.pop() {
Some(entry) if vs.is_empty() => entry.to_scim_kanidm(self),
_ => {
if vs.is_empty() {
Err(OperationError::NoMatchingEntries)
} else {
// Multiple entries matched, should not be possible!
Err(OperationError::UniqueConstraintViolation)
}
}
}
}
pub fn scim_create(&mut self, _scim_create: ScimCreateEvent) -> Result<(), OperationError> {
todo!();
}
pub fn scim_delete(&mut self, scim_delete: ScimDeleteEvent) -> Result<(), OperationError> {
let ScimDeleteEvent {
ident,
target,
class,
} = scim_delete;
let filter_intent = filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(target)));
let f_intent_valid = filter_intent
.validate(self.get_schema())
.map_err(OperationError::SchemaViolation)?;
let filter = filter!(f_and!([
f_eq(Attribute::Uuid, PartialValue::Uuid(target)),
f_eq(Attribute::Class, class.into())
]));
let f_valid = filter
.validate(self.get_schema())
.map_err(OperationError::SchemaViolation)?;
let de = DeleteEvent {
ident,
filter: f_valid,
filter_orig: f_intent_valid,
};
self.delete(&de)
}
pub(crate) fn resolve_scim_json_put(
&mut self,
attr: &Attribute,
value: Option<JsonValue>,
) -> Result<Option<ValueSet>, OperationError> {
let schema = self.get_schema();
// Lookup the attr
let Some(schema_a) = schema.get_attributes().get(attr) else {
// No attribute of this name exists - fail fast, there is no point to
// proceed, as nothing can be satisfied.
return Err(OperationError::InvalidAttributeName(attr.to_string()));
};
let Some(value) = value else {
// It's a none so the value needs to be unset, and the attr DOES exist in
// schema.
return Ok(None);
};
self.resolve_scim_json(schema_a, value).map(Some)
}
pub(crate) fn resolve_scim_json_post(
&mut self,
attr: &Attribute,
value: JsonValue,
) -> Result<ValueSet, OperationError> {
let schema = self.get_schema();
// Lookup the attr
let Some(schema_a) = schema.get_attributes().get(attr) else {
// No attribute of this name exists - fail fast, there is no point to
// proceed, as nothing can be satisfied.
return Err(OperationError::InvalidAttributeName(attr.to_string()));
};
self.resolve_scim_json(schema_a, value)
}
fn resolve_scim_json(
&mut self,
schema_a: &SchemaAttribute,
value: JsonValue,
) -> Result<ValueSet, OperationError> {
let resolve_status = match schema_a.syntax {
SyntaxType::Utf8String => ValueSetUtf8::from_scim_json_put(value),
SyntaxType::Utf8StringInsensitive => ValueSetIutf8::from_scim_json_put(value),
SyntaxType::Uuid => ValueSetUuid::from_scim_json_put(value),
SyntaxType::Boolean => ValueSetBool::from_scim_json_put(value),
SyntaxType::SyntaxId => ValueSetSyntax::from_scim_json_put(value),
SyntaxType::IndexId => ValueSetIndex::from_scim_json_put(value),
SyntaxType::ReferenceUuid => ValueSetRefer::from_scim_json_put(value),
SyntaxType::Utf8StringIname => ValueSetIname::from_scim_json_put(value),
SyntaxType::NsUniqueId => ValueSetNsUniqueId::from_scim_json_put(value),
SyntaxType::DateTime => ValueSetDateTime::from_scim_json_put(value),
SyntaxType::EmailAddress => ValueSetEmailAddress::from_scim_json_put(value),
SyntaxType::Url => ValueSetUrl::from_scim_json_put(value),
SyntaxType::OauthScope => ValueSetOauthScope::from_scim_json_put(value),
SyntaxType::OauthScopeMap => ValueSetOauthScopeMap::from_scim_json_put(value),
SyntaxType::OauthClaimMap => ValueSetOauthClaimMap::from_scim_json_put(value),
SyntaxType::UiHint => ValueSetUiHint::from_scim_json_put(value),
SyntaxType::CredentialType => ValueSetCredentialType::from_scim_json_put(value),
SyntaxType::Certificate => ValueSetCertificate::from_scim_json_put(value),
SyntaxType::SshKey => ValueSetSshKey::from_scim_json_put(value),
SyntaxType::Uint32 => ValueSetUint32::from_scim_json_put(value),
// Not Yet ... if ever
// SyntaxType::JsonFilter => ValueSetJsonFilter::from_scim_json_put(value),
SyntaxType::JsonFilter => Err(OperationError::InvalidAttribute(
"Json Filters are not able to be set.".to_string(),
)),
// Can't be set currently as these are only internally generated for key-id's
// SyntaxType::HexString => ValueSetHexString::from_scim_json_put(value),
SyntaxType::HexString => Err(OperationError::InvalidAttribute(
"Hex strings are not able to be set.".to_string(),
)),
// Can't be set until we have better error handling in the set paths
// SyntaxType::Image => ValueSetImage::from_scim_json_put(value),
SyntaxType::Image => Err(OperationError::InvalidAttribute(
"Images are not able to be set.".to_string(),
)),
// Can't be set yet, mostly as I'm lazy
// SyntaxType::WebauthnAttestationCaList => {
// ValueSetWebauthnAttestationCaList::from_scim_json_put(value)
// }
SyntaxType::WebauthnAttestationCaList => Err(OperationError::InvalidAttribute(
"Webauthn Attestation Ca Lists are not able to be set.".to_string(),
)),
// Syntax types that can not be submitted
SyntaxType::Credential => Err(OperationError::InvalidAttribute(
"Credentials are not able to be set.".to_string(),
)),
SyntaxType::SecretUtf8String => Err(OperationError::InvalidAttribute(
"Secrets are not able to be set.".to_string(),
)),
SyntaxType::SecurityPrincipalName => Err(OperationError::InvalidAttribute(
"SPNs are not able to be set.".to_string(),
)),
SyntaxType::Cid => Err(OperationError::InvalidAttribute(
"CIDs are not able to be set.".to_string(),
)),
SyntaxType::PrivateBinary => Err(OperationError::InvalidAttribute(
"Private Binaries are not able to be set.".to_string(),
)),
SyntaxType::IntentToken => Err(OperationError::InvalidAttribute(
"Intent Tokens are not able to be set.".to_string(),
)),
SyntaxType::Passkey => Err(OperationError::InvalidAttribute(
"Passkeys are not able to be set.".to_string(),
)),
SyntaxType::AttestedPasskey => Err(OperationError::InvalidAttribute(
"Attested Passkeys are not able to be set.".to_string(),
)),
SyntaxType::Session => Err(OperationError::InvalidAttribute(
"Sessions are not able to be set.".to_string(),
)),
SyntaxType::JwsKeyEs256 => Err(OperationError::InvalidAttribute(
"Jws ES256 Private Keys are not able to be set.".to_string(),
)),
SyntaxType::JwsKeyRs256 => Err(OperationError::InvalidAttribute(
"Jws RS256 Private Keys are not able to be set.".to_string(),
)),
SyntaxType::Oauth2Session => Err(OperationError::InvalidAttribute(
"Sessions are not able to be set.".to_string(),
)),
SyntaxType::TotpSecret => Err(OperationError::InvalidAttribute(
"TOTP Secrets are not able to be set.".to_string(),
)),
SyntaxType::ApiToken => Err(OperationError::InvalidAttribute(
"API Tokens are not able to be set.".to_string(),
)),
SyntaxType::AuditLogString => Err(OperationError::InvalidAttribute(
"Audit Strings are not able to be set.".to_string(),
)),
SyntaxType::EcKeyPrivate => Err(OperationError::InvalidAttribute(
"EC Private Keys are not able to be set.".to_string(),
)),
SyntaxType::KeyInternal => Err(OperationError::InvalidAttribute(
"Key Internal Structures are not able to be set.".to_string(),
)),
SyntaxType::ApplicationPassword => Err(OperationError::InvalidAttribute(
"Application Passwords are not able to be set.".to_string(),
)),
}?;
match resolve_status {
ValueSetResolveStatus::Resolved(vs) => Ok(vs),
ValueSetResolveStatus::NeedsResolution(vs_inter) => {
self.resolve_valueset_intermediate(vs_inter)
}
}
}
}
#[cfg(test)]
mod tests {
use super::ScimEntryPutEvent;
use crate::prelude::*;
use kanidm_proto::scim_v1::client::ScimEntryPutKanidm;
use kanidm_proto::scim_v1::server::ScimReference;
#[qs_test]
async fn scim_put_basic(server: &QueryServer) {
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
let idm_admin_entry = server_txn.internal_search_uuid(UUID_IDM_ADMIN).unwrap();
let idm_admin_ident = Identity::from_impersonate_entry_readwrite(idm_admin_entry);
// Make an entry.
let group_uuid = Uuid::new_v4();
// Add members to our groups to test reference handling in scim
let extra1_uuid = Uuid::new_v4();
let extra2_uuid = Uuid::new_v4();
let extra3_uuid = Uuid::new_v4();
let e1 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::Group.to_value()),
(Attribute::Name, Value::new_iname("testgroup")),
(Attribute::Uuid, Value::Uuid(group_uuid))
);
let e2 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::Group.to_value()),
(Attribute::Name, Value::new_iname("extra_1")),
(Attribute::Uuid, Value::Uuid(extra1_uuid))
);
let e3 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::Group.to_value()),
(Attribute::Name, Value::new_iname("extra_2")),
(Attribute::Uuid, Value::Uuid(extra2_uuid))
);
let e4 = entry_init!(
(Attribute::Class, EntryClass::Object.to_value()),
(Attribute::Class, EntryClass::Group.to_value()),
(Attribute::Name, Value::new_iname("extra_3")),
(Attribute::Uuid, Value::Uuid(extra3_uuid))
);
assert!(server_txn.internal_create(vec![e1, e2, e3, e4]).is_ok());
// Set an attr
let put = ScimEntryPutKanidm {
id: group_uuid,
attrs: [(Attribute::Description, Some("Group Description".into()))].into(),
};
let put_generic = put.try_into().unwrap();
let put_event =
ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
.expect("Failed to resolve data type");
let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
let desc = updated_entry.attrs.get(&Attribute::Description).unwrap();
match desc {
ScimValueKanidm::String(gdesc) if gdesc == "Group Description" => {}
_ => unreachable!("Expected a string"),
};
// null removes attr
let put = ScimEntryPutKanidm {
id: group_uuid,
attrs: [(Attribute::Description, None)].into(),
};
let put_generic = put.try_into().unwrap();
let put_event =
ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
.expect("Failed to resolve data type");
let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
assert!(!updated_entry.attrs.contains_key(&Attribute::Description));
// set one
let put = ScimEntryPutKanidm {
id: group_uuid,
attrs: [(
Attribute::Member,
Some(ScimValueKanidm::EntryReferences(vec![ScimReference {
uuid: extra1_uuid,
// Doesn't matter what this is, because there is a UUID, it's ignored
value: String::default(),
}])),
)]
.into(),
};
let put_generic = put.try_into().unwrap();
let put_event =
ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
.expect("Failed to resolve data type");
let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
trace!(?members);
match members {
ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 1 => {
assert!(member_set.contains(&ScimReference {
uuid: extra1_uuid,
value: "extra_1@example.com".to_string(),
}));
}
_ => unreachable!("Expected 1 member"),
};
// set many
let put = ScimEntryPutKanidm {
id: group_uuid,
attrs: [(
Attribute::Member,
Some(ScimValueKanidm::EntryReferences(vec![
ScimReference {
uuid: extra1_uuid,
value: String::default(),
},
ScimReference {
uuid: extra2_uuid,
value: String::default(),
},
ScimReference {
uuid: extra3_uuid,
value: String::default(),
},
])),
)]
.into(),
};
let put_generic = put.try_into().unwrap();
let put_event =
ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
.expect("Failed to resolve data type");
let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
trace!(?members);
match members {
ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 3 => {
assert!(member_set.contains(&ScimReference {
uuid: extra1_uuid,
value: "extra_1@example.com".to_string(),
}));
assert!(member_set.contains(&ScimReference {
uuid: extra2_uuid,
value: "extra_2@example.com".to_string(),
}));
assert!(member_set.contains(&ScimReference {
uuid: extra3_uuid,
value: "extra_3@example.com".to_string(),
}));
}
_ => unreachable!("Expected 3 members"),
};
// set many with a removal
let put = ScimEntryPutKanidm {
id: group_uuid,
attrs: [(
Attribute::Member,
Some(ScimValueKanidm::EntryReferences(vec![
ScimReference {
uuid: extra1_uuid,
value: String::default(),
},
ScimReference {
uuid: extra3_uuid,
value: String::default(),
},
])),
)]
.into(),
};
let put_generic = put.try_into().unwrap();
let put_event =
ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
.expect("Failed to resolve data type");
let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
let members = updated_entry.attrs.get(&Attribute::Member).unwrap();
trace!(?members);
match members {
ScimValueKanidm::EntryReferences(member_set) if member_set.len() == 2 => {
assert!(member_set.contains(&ScimReference {
uuid: extra1_uuid,
value: "extra_1@example.com".to_string(),
}));
assert!(member_set.contains(&ScimReference {
uuid: extra3_uuid,
value: "extra_3@example.com".to_string(),
}));
// Member 2 is gone
assert!(!member_set.contains(&ScimReference {
uuid: extra2_uuid,
value: "extra_2@example.com".to_string(),
}));
}
_ => unreachable!("Expected 2 members"),
};
// empty set removes attr
let put = ScimEntryPutKanidm {
id: group_uuid,
attrs: [(Attribute::Member, None)].into(),
};
let put_generic = put.try_into().unwrap();
let put_event =
ScimEntryPutEvent::try_from(idm_admin_ident.clone(), put_generic, &mut server_txn)
.expect("Failed to resolve data type");
let updated_entry = server_txn.scim_put(put_event).expect("Failed to put");
assert!(!updated_entry.attrs.contains_key(&Attribute::Member));
}
}