diff --git a/Cargo.lock b/Cargo.lock index c22bfb148..e049c4274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,9 +113,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" [[package]] name = "arc-swap" @@ -665,9 +665,9 @@ checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] name = "cc" -version = "1.2.13" +version = "1.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7777341816418c02e033934a09f20dc0ccaf65a5201ef8a450ae0105a573fda" +checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" dependencies = [ "shlex", ] @@ -727,9 +727,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.28" +version = "4.5.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" +checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" dependencies = [ "clap_builder", "clap_derive", @@ -737,9 +737,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.5.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" dependencies = [ "anstream", "anstyle", @@ -749,9 +749,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.44" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375f9d8255adeeedd51053574fd8d4ba875ea5fa558e86617b07f09f1680c8b6" +checksum = "1e3040c8291884ddf39445dc033c70abc2bc44a42f0a3a00571a0f483a83f0cd" dependencies = [ "clap", ] @@ -812,16 +812,16 @@ dependencies = [ [[package]] name = "concread" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba00cef522c2597dfbb0a8d1b0ac8ac2b99714f50cc354cda71da63164da0be" +checksum = "0a06c26e76cd1d7a88a44324d0cf18b11589be552e97af09bee345f7e7334c6d" dependencies = [ "ahash", "arc-swap", "crossbeam-epoch", "crossbeam-queue", "crossbeam-utils", - "lru", + "lru 0.13.0", "smallvec", "sptr", "tokio", @@ -1021,9 +1021,9 @@ dependencies = [ [[package]] name = "csv-core" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" dependencies = [ "memchr", ] @@ -1126,9 +1126,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" [[package]] name = "der" @@ -1292,9 +1292,9 @@ dependencies = [ [[package]] name = "document-features" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" dependencies = [ "litrs", ] @@ -1383,9 +1383,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -2240,9 +2240,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" dependencies = [ "atomic-waker", "bytes", @@ -2443,7 +2443,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2 0.4.7", + "h2 0.4.8", "http 1.2.0", "http-body 1.0.1", "httparse", @@ -2801,6 +2801,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -3085,7 +3094,7 @@ dependencies = [ "kanidmd_core", "kanidmd_testkit", "libc", - "lru", + "lru 0.12.5", "mimalloc", "notify-debouncer-full", "prctl", @@ -3467,9 +3476,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "lru" @@ -3480,6 +3489,15 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "lru" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "malloced" version = "1.3.1" @@ -3567,9 +3585,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", ] @@ -3622,9 +3640,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ "libc", "log", @@ -3933,9 +3951,9 @@ checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "openssl" -version = "0.10.70" +version = "0.10.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -3965,9 +3983,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.105" +version = "0.9.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" dependencies = [ "cc", "libc", @@ -4367,9 +4385,9 @@ checksum = "744a264d26b88a6a7e37cbad97953fa233b94d585236310bcbc88474b4092d79" [[package]] name = "prost" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c0fef6c4230e4ccf618a35c59d7ede15dea37de8427500f50aff708806e42ec" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", "prost-derive", @@ -4377,12 +4395,12 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.98", @@ -4460,9 +4478,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" +checksum = "e46f3055866785f6b92bc6164b76be02ca8f2eb4b002c0354b28cf4c119e5944" dependencies = [ "cfg_aliases", "libc", @@ -4513,9 +4531,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ "bitflags 2.8.0", ] @@ -4665,7 +4683,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.7", + "h2 0.4.8", "http 1.2.0", "http-body 1.0.1", "http-body-util", @@ -4713,15 +4731,14 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.8" +version = "0.17.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "d34b5020fcdea098ef7d95e9f89ec15952123a4a039badd09fabebe9e963e839" dependencies = [ "cc", "cfg-if", "getrandom 0.2.15", "libc", - "spin", "untrusted", "windows-sys 0.52.0", ] @@ -4832,9 +4849,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.22" +version = "0.23.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9263ab4eb695e42321db096e3b8fbd715a59b154d5c88d82db2175b681ba7" +checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" dependencies = [ "once_cell", "ring", @@ -5017,9 +5034,9 @@ checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] @@ -5055,9 +5072,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", @@ -5066,9 +5083,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.139" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" dependencies = [ "itoa", "memchr", @@ -5224,9 +5241,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" dependencies = [ "serde", ] @@ -5427,9 +5444,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.16.0" +version = "3.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" dependencies = [ "cfg-if", "fastrand", @@ -5564,9 +5581,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tls_codec" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e78c9c330f8c85b2bae7c8368f2739157db9991235123aa1b15ef9502bfb6a" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" dependencies = [ "tls_codec_derive", "zeroize", @@ -5574,9 +5591,9 @@ dependencies = [ [[package]] name = "tls_codec_derive" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9ef545650e79f30233c0003bcc2504d7efac6dad25fca40744de773fe2049c" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", @@ -5705,7 +5722,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", - "h2 0.4.7", + "h2 0.4.8", "http 1.2.0", "http-body 1.0.1", "http-body-util", @@ -5939,9 +5956,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicase" @@ -5957,9 +5974,9 @@ checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" [[package]] name = "unicode-normalization" @@ -6069,9 +6086,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0" +checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1" dependencies = [ "getrandom 0.3.1", "serde", diff --git a/proto/src/attribute.rs b/proto/src/attribute.rs index 9822ed355..fbd97eeb7 100644 --- a/proto/src/attribute.rs +++ b/proto/src/attribute.rs @@ -631,6 +631,71 @@ impl From for String { } } +/// Sub attributes are a component of SCIM, allowing tagged sub properties of a complex +/// attribute to be accessed. +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "lowercase", try_from = "&str", into = "AttrString")] +pub enum SubAttribute { + /// Denotes a primary value. + Primary, + + #[cfg(not(test))] + Custom(AttrString), +} + +impl From for AttrString { + fn from(val: SubAttribute) -> Self { + AttrString::from(val.as_str()) + } +} + +impl From<&str> for SubAttribute { + fn from(value: &str) -> Self { + Self::inner_from_str(value) + } +} + +impl FromStr for SubAttribute { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self::inner_from_str(value)) + } +} + +impl SubAttribute { + pub fn as_str(&self) -> &str { + match self { + SubAttribute::Primary => SUB_ATTR_PRIMARY, + #[cfg(not(test))] + SubAttribute::Custom(s) => s, + } + } + + // We allow this because the standard lib from_str is fallible, and we want an infallible version. + #[allow(clippy::should_implement_trait)] + fn inner_from_str(value: &str) -> Self { + // Could this be something like heapless to save allocations? Also gives a way + // to limit length of str? + match value.to_lowercase().as_str() { + SUB_ATTR_PRIMARY => SubAttribute::Primary, + + #[cfg(not(test))] + _ => SubAttribute::Custom(AttrString::from(value)), + + // Allowed only in tests + #[allow(clippy::unreachable)] + #[cfg(test)] + _ => { + unreachable!( + "Check that you've implemented the SubAttribute conversion for {:?}", + value + ); + } + } + } +} + #[cfg(test)] mod test { use super::Attribute; diff --git a/proto/src/constants.rs b/proto/src/constants.rs index 6c3fa014c..03d58a36e 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -220,6 +220,8 @@ pub const ATTR_VERSION: &str = "version"; pub const ATTR_WEBAUTHN_ATTESTATION_CA_LIST: &str = "webauthn_attestation_ca_list"; pub const ATTR_ALLOW_PRIMARY_CRED_FALLBACK: &str = "allow_primary_cred_fallback"; +pub const SUB_ATTR_PRIMARY: &str = "primary"; + pub const OAUTH2_SCOPE_EMAIL: &str = ATTR_EMAIL; pub const OAUTH2_SCOPE_GROUPS: &str = "groups"; pub const OAUTH2_SCOPE_SSH_PUBLICKEYS: &str = "ssh_publickeys"; diff --git a/proto/src/scim_v1/client.rs b/proto/src/scim_v1/client.rs index ae5a8a330..ff2dd07fc 100644 --- a/proto/src/scim_v1/client.rs +++ b/proto/src/scim_v1/client.rs @@ -1,7 +1,7 @@ //! These are types that a client will send to the server. use super::ScimEntryGetQuery; use super::ScimOauth2ClaimMapJoinChar; -use crate::attribute::Attribute; +use crate::attribute::{Attribute, SubAttribute}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use serde_with::formats::PreferMany; @@ -134,3 +134,59 @@ impl TryFrom for ScimEntryPutGeneric { }) } } + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct AttrPath { + pub a: Attribute, + pub s: Option, +} + +impl From for AttrPath { + fn from(a: Attribute) -> Self { + Self { a, s: None } + } +} + +impl From<(Attribute, SubAttribute)> for AttrPath { + fn from((a, s): (Attribute, SubAttribute)) -> Self { + Self { a, s: Some(s) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum ScimFilter { + Or(Box, Box), + And(Box, Box), + Not(Box), + + Present(AttrPath), + Equal(AttrPath, JsonValue), + NotEqual(AttrPath, JsonValue), + Contains(AttrPath, JsonValue), + StartsWith(AttrPath, JsonValue), + EndsWith(AttrPath, JsonValue), + Greater(AttrPath, JsonValue), + Less(AttrPath, JsonValue), + GreaterOrEqual(AttrPath, JsonValue), + LessOrEqual(AttrPath, JsonValue), + + Complex(Attribute, Box), +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum ScimComplexFilter { + Or(Box, Box), + And(Box, Box), + Not(Box), + + Present(SubAttribute), + Equal(SubAttribute, JsonValue), + NotEqual(SubAttribute, JsonValue), + Contains(SubAttribute, JsonValue), + StartsWith(SubAttribute, JsonValue), + EndsWith(SubAttribute, JsonValue), + Greater(SubAttribute, JsonValue), + Less(SubAttribute, JsonValue), + GreaterOrEqual(SubAttribute, JsonValue), + LessOrEqual(SubAttribute, JsonValue), +} diff --git a/proto/src/scim_v1/server.rs b/proto/src/scim_v1/server.rs index 555071f16..f99784fdc 100644 --- a/proto/src/scim_v1/server.rs +++ b/proto/src/scim_v1/server.rs @@ -257,6 +257,98 @@ pub enum ScimValueKanidm { UiHints(Vec), } +#[serde_as] +#[derive(Serialize, Debug, Clone, ToSchema)] +pub struct ScimPerson { + pub uuid: Uuid, + pub name: String, + pub displayname: String, + pub spn: String, + pub description: Option, + pub mails: Vec, + pub managed_by: Option, + pub groups: Vec, +} + +impl TryFrom for ScimPerson { + type Error = (); + + fn try_from(scim_entry: ScimEntryKanidm) -> Result { + let uuid = scim_entry.header.id; + let name = scim_entry + .attrs + .get(&Attribute::Name) + .and_then(|v| match v { + ScimValueKanidm::String(s) => Some(s.clone()), + _ => None, + }) + .ok_or(())?; + + let displayname = scim_entry + .attrs + .get(&Attribute::DisplayName) + .and_then(|v| match v { + ScimValueKanidm::String(s) => Some(s.clone()), + _ => None, + }) + .ok_or(())?; + + let spn = scim_entry + .attrs + .get(&Attribute::Spn) + .and_then(|v| match v { + ScimValueKanidm::String(s) => Some(s.clone()), + _ => None, + }) + .ok_or(())?; + + let description = scim_entry + .attrs + .get(&Attribute::Description) + .and_then(|v| match v { + ScimValueKanidm::String(s) => Some(s.clone()), + _ => None, + }); + + let mails = scim_entry + .attrs + .get(&Attribute::Mail) + .and_then(|v| match v { + ScimValueKanidm::Mail(m) => Some(m.clone()), + _ => None, + }) + .unwrap_or_default(); + + let groups = scim_entry + .attrs + .get(&Attribute::DirectMemberOf) + .and_then(|v| match v { + ScimValueKanidm::EntryReferences(v) => Some(v.clone()), + _ => None, + }) + .unwrap_or_default(); + + let managed_by = scim_entry + .attrs + .get(&Attribute::EntryManagedBy) + .and_then(|v| match v { + ScimValueKanidm::EntryReference(v) => Some(v.clone()), + _ => None, + }); + + Ok(ScimPerson { + uuid, + name, + displayname, + spn, + description, + mails, + managed_by, + groups, + }) + } +} + impl From for ScimValueKanidm { fn from(b: bool) -> Self { Self::Bool(b) diff --git a/proto/src/v1/mod.rs b/proto/src/v1/mod.rs index f30c694e2..d94cde2a7 100644 --- a/proto/src/v1/mod.rs +++ b/proto/src/v1/mod.rs @@ -19,7 +19,7 @@ pub use self::auth::*; pub use self::unix::*; /// The type of Account in use. -#[derive(Clone, Copy, Debug, ToSchema)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, ToSchema)] pub enum AccountType { Person, ServiceAccount, diff --git a/server/core/src/actors/v1_scim.rs b/server/core/src/actors/v1_scim.rs index 723869c45..e6cffa91c 100644 --- a/server/core/src/actors/v1_scim.rs +++ b/server/core/src/actors/v1_scim.rs @@ -1,6 +1,6 @@ use super::{QueryServerReadV1, QueryServerWriteV1}; use kanidm_proto::scim_v1::{ - server::ScimEntryKanidm, ScimEntryGetQuery, ScimSyncRequest, ScimSyncState, + client::ScimFilter, server::ScimEntryKanidm, ScimEntryGetQuery, ScimSyncRequest, ScimSyncState, }; use kanidmd_lib::idm::scim::{ GenerateScimSyncTokenEvent, ScimSyncFinaliseEvent, ScimSyncTerminateEvent, ScimSyncUpdateEvent, @@ -229,4 +229,27 @@ impl QueryServerReadV1 { .qs_read .scim_entry_id_get_ext(target_uuid, class, query, ident) } + + #[instrument( + level = "info", + skip_all, + fields(uuid = ?eventid) + )] + pub async fn scim_entry_search( + &self, + client_auth_info: ClientAuthInfo, + eventid: Uuid, + filter: ScimFilter, + query: ScimEntryGetQuery, + ) -> Result, OperationError> { + let ct = duration_from_epoch_now(); + let mut idms_prox_read = self.idms.proxy_read().await?; + let ident = idms_prox_read + .validate_client_auth_info_to_ident(client_auth_info, ct) + .inspect_err(|err| { + error!(?err, "Invalid identity"); + })?; + + idms_prox_read.qs_read.scim_search_ext(ident, filter, query) + } } diff --git a/server/core/src/https/views/admin/mod.rs b/server/core/src/https/views/admin/mod.rs new file mode 100644 index 000000000..e728e33b0 --- /dev/null +++ b/server/core/src/https/views/admin/mod.rs @@ -0,0 +1,19 @@ +use crate::https::ServerState; +use axum::routing::get; +use axum::Router; +use axum_htmx::HxRequestGuardLayer; + +mod persons; + +pub fn admin_router() -> Router { + let unguarded_router = Router::new() + .route("/persons", get(persons::view_persons_get)) + .route( + "/person/:person_uuid/view", + get(persons::view_person_view_get), + ); + + let guarded_router = Router::new().layer(HxRequestGuardLayer::new("/ui")); + + Router::new().merge(unguarded_router).merge(guarded_router) +} diff --git a/server/core/src/https/views/admin/persons.rs b/server/core/src/https/views/admin/persons.rs new file mode 100644 index 000000000..e1a2d723f --- /dev/null +++ b/server/core/src/https/views/admin/persons.rs @@ -0,0 +1,193 @@ +use crate::https::extractors::{DomainInfo, VerifiedClientInformation}; +use crate::https::middleware::KOpId; +use crate::https::views::errors::HtmxError; +use crate::https::views::navbar::NavbarCtx; +use crate::https::views::Urls; +use crate::https::ServerState; +use askama::Template; +use axum::extract::{Path, State}; +use axum::http::Uri; +use axum::response::{ErrorResponse, IntoResponse, Response}; +use axum::Extension; +use axum_htmx::{HxPushUrl, HxRequest}; +use futures_util::TryFutureExt; +use kanidm_proto::attribute::Attribute; +use kanidm_proto::internal::OperationError; +use kanidm_proto::scim_v1::client::ScimFilter; +use kanidm_proto::scim_v1::server::{ScimEffectiveAccess, ScimEntryKanidm, ScimPerson}; +use kanidm_proto::scim_v1::ScimEntryGetQuery; +use kanidmd_lib::constants::EntryClass; +use kanidmd_lib::idm::server::DomainInfoRead; +use kanidmd_lib::idm::ClientAuthInfo; +use std::str::FromStr; +use uuid::Uuid; + +const PERSON_ATTRIBUTES: [Attribute; 9] = [ + Attribute::Uuid, + Attribute::Description, + Attribute::Name, + Attribute::DisplayName, + Attribute::Spn, + Attribute::Mail, + Attribute::Class, + Attribute::EntryManagedBy, + Attribute::DirectMemberOf, +]; + +#[derive(Template)] +#[template(path = "admin/admin_panel_template.html")] +pub(crate) struct PersonsView { + navbar_ctx: NavbarCtx, + partial: PersonsPartialView, +} + +#[derive(Template)] +#[template(path = "admin/admin_persons_partial.html")] +struct PersonsPartialView { + persons: Vec<(ScimPerson, ScimEffectiveAccess)>, +} + +#[derive(Template)] +#[template(path = "admin/admin_panel_template.html")] +struct PersonView { + partial: PersonViewPartial, + navbar_ctx: NavbarCtx, +} + +#[derive(Template)] +#[template(path = "admin/admin_person_view_partial.html")] +struct PersonViewPartial { + person: ScimPerson, + scim_effective_access: ScimEffectiveAccess, +} + +pub(crate) async fn view_person_view_get( + State(state): State, + HxRequest(is_htmx): HxRequest, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + Path(uuid): Path, + DomainInfo(domain_info): DomainInfo, +) -> axum::response::Result { + let (person, scim_effective_access) = + get_person_info(uuid, state, &kopid, client_auth_info, domain_info.clone()).await?; + let person_partial = PersonViewPartial { + person, + scim_effective_access, + }; + + let path_string = format!("/ui/admin/person/{uuid}/view"); + let uri = Uri::from_str(path_string.as_str()) + .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?; + let push_url = HxPushUrl(uri); + Ok(if is_htmx { + (push_url, person_partial).into_response() + } else { + ( + push_url, + PersonView { + partial: person_partial, + navbar_ctx: NavbarCtx { domain_info }, + }, + ) + .into_response() + }) +} + +pub(crate) async fn view_persons_get( + State(state): State, + HxRequest(is_htmx): HxRequest, + Extension(kopid): Extension, + DomainInfo(domain_info): DomainInfo, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, +) -> axum::response::Result { + let persons = get_persons_info(state, &kopid, client_auth_info, domain_info.clone()).await?; + let persons_partial = PersonsPartialView { persons }; + + let push_url = HxPushUrl(Uri::from_static("/ui/admin/persons")); + Ok(if is_htmx { + (push_url, persons_partial).into_response() + } else { + ( + push_url, + PersonsView { + navbar_ctx: NavbarCtx { domain_info }, + partial: persons_partial, + }, + ) + .into_response() + }) +} + +async fn get_person_info( + uuid: Uuid, + state: ServerState, + kopid: &KOpId, + client_auth_info: ClientAuthInfo, + domain_info: DomainInfoRead, +) -> Result<(ScimPerson, ScimEffectiveAccess), ErrorResponse> { + let scim_entry: ScimEntryKanidm = state + .qe_r_ref + .scim_entry_id_get( + client_auth_info.clone(), + kopid.eventid, + uuid.to_string(), + EntryClass::Person, + ScimEntryGetQuery { + attributes: Some(Vec::from(PERSON_ATTRIBUTES)), + ext_access_check: true, + }, + ) + .map_err(|op_err| HtmxError::new(kopid, op_err, domain_info.clone())) + .await?; + + if let Some(personinfo_info) = scimentry_into_personinfo(scim_entry) { + Ok(personinfo_info) + } else { + Err(HtmxError::new(kopid, OperationError::InvalidState, domain_info.clone()).into()) + } +} + +async fn get_persons_info( + state: ServerState, + kopid: &KOpId, + client_auth_info: ClientAuthInfo, + domain_info: DomainInfoRead, +) -> Result, ErrorResponse> { + let filter = ScimFilter::Equal(Attribute::Class.into(), EntryClass::Person.into()); + + let base: Vec = state + .qe_r_ref + .scim_entry_search( + client_auth_info.clone(), + kopid.eventid, + filter, + ScimEntryGetQuery { + attributes: Some(Vec::from(PERSON_ATTRIBUTES)), + ext_access_check: true, + }, + ) + .map_err(|op_err| HtmxError::new(kopid, op_err, domain_info.clone())) + .await?; + + // TODO: inefficient to sort here + let mut persons: Vec<_> = base + .into_iter() + // TODO: Filtering away unsuccessful entries may not be desired. + .filter_map(scimentry_into_personinfo) + .collect(); + + persons.sort_by_key(|(sp, _)| sp.uuid); + persons.reverse(); + + Ok(persons) +} + +fn scimentry_into_personinfo( + scim_entry: ScimEntryKanidm, +) -> Option<(ScimPerson, ScimEffectiveAccess)> { + let scim_effective_access = scim_entry.ext_access_check.clone()?; // TODO: This should be an error msg. + let person = ScimPerson::try_from(scim_entry).ok()?; + + Some((person, scim_effective_access)) +} diff --git a/server/core/src/https/views/apps.rs b/server/core/src/https/views/apps.rs index a68339524..9c812d126 100644 --- a/server/core/src/https/views/apps.rs +++ b/server/core/src/https/views/apps.rs @@ -45,14 +45,14 @@ pub(crate) async fn view_apps_get( .await .map_err(|old| HtmxError::new(&kopid, old, domain_info.clone()))?; + let apps_partial = AppsPartialView { apps: app_links }; + Ok({ - ( - HxPushUrl(Uri::from_static(Urls::Apps.as_ref())), - AppsView { - navbar_ctx: NavbarCtx { domain_info }, - apps_partial: AppsPartialView { apps: app_links }, - }, - ) - .into_response() + let apps_view = AppsView { + navbar_ctx: NavbarCtx { domain_info }, + + apps_partial, + }; + (HxPushUrl(Uri::from_static(Urls::Apps.as_ref())), apps_view).into_response() }) } diff --git a/server/core/src/https/views/enrol.rs b/server/core/src/https/views/enrol.rs index ee92704c7..6908a6155 100644 --- a/server/core/src/https/views/enrol.rs +++ b/server/core/src/https/views/enrol.rs @@ -105,6 +105,7 @@ pub(crate) async fn view_enrol_get( Ok(ProfileView { navbar_ctx: NavbarCtx { domain_info }, + profile_partial: EnrolDeviceView { menu_active_item: ProfileMenuItems::EnrolDevice, qr_code_svg, diff --git a/server/core/src/https/views/errors.rs b/server/core/src/https/views/errors.rs index 3686772d8..04c973417 100644 --- a/server/core/src/https/views/errors.rs +++ b/server/core/src/https/views/errors.rs @@ -1,6 +1,6 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; -use axum_htmx::{HxReswap, HxRetarget, SwapOption}; +use axum_htmx::{HxEvent, HxResponseTrigger, HxReswap, HxRetarget, SwapOption}; use kanidmd_lib::idm::server::DomainInfoRead; use utoipa::ToSchema; use uuid::Uuid; @@ -8,7 +8,7 @@ use uuid::Uuid; use kanidm_proto::internal::OperationError; use crate::https::middleware::KOpId; -use crate::https::views::UnrecoverableErrorView; +use crate::https::views::{ErrorToastPartial, UnrecoverableErrorView}; // #[derive(Template)] // #[template(path = "recoverable_error_partial.html")] // struct ErrorPartialView { @@ -41,7 +41,23 @@ impl IntoResponse for HtmxError { | OperationError::SessionExpired | OperationError::InvalidSessionState => Redirect::to("/ui").into_response(), OperationError::SystemProtectedObject | OperationError::AccessDenied => { - (StatusCode::FORBIDDEN, body).into_response() + let trigger = HxResponseTrigger::after_swap([HxEvent::new( + "permissionDenied".to_string(), + )]); + ( + trigger, + HxRetarget("main".to_string()), + HxReswap(SwapOption::BeforeEnd), + ( + StatusCode::FORBIDDEN, + ErrorToastPartial { + err_code: inner, + operation_id: kopid, + }, + ) + .into_response(), + ) + .into_response() } OperationError::NoMatchingEntries => { (StatusCode::NOT_FOUND, body).into_response() diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index 1c95a2709..b52546180 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -8,6 +8,7 @@ use axum::{ use axum_htmx::HxRequestGuardLayer; +use crate::https::views::admin::admin_router; use constants::Urls; use kanidmd_lib::{ idm::server::DomainInfoRead, @@ -16,6 +17,7 @@ use kanidmd_lib::{ use crate::https::ServerState; +mod admin; mod apps; pub(crate) mod constants; mod cookies; @@ -36,6 +38,13 @@ struct UnrecoverableErrorView { domain_info: DomainInfoRead, } +#[derive(Template)] +#[template(path = "admin/error_toast.html")] +struct ErrorToastPartial { + err_code: OperationError, + operation_id: Uuid, +} + pub fn view_router() -> Router { let mut unguarded_router = Router::new() .route( @@ -122,7 +131,11 @@ pub fn view_router() -> Router { .route("/api/cu_commit", post(reset::commit)) .layer(HxRequestGuardLayer::new("/ui")); - Router::new().merge(unguarded_router).merge(guarded_router) + let admin_router = admin_router(); + Router::new() + .merge(unguarded_router) + .merge(guarded_router) + .nest("/admin", admin_router) } /// Serde deserialization decorator to map empty Strings to None, diff --git a/server/core/src/https/views/profile.rs b/server/core/src/https/views/profile.rs index 002abaaba..9d00bbe1c 100644 --- a/server/core/src/https/views/profile.rs +++ b/server/core/src/https/views/profile.rs @@ -48,6 +48,7 @@ pub(crate) async fn view_profile_get( Ok(ProfileView { navbar_ctx: NavbarCtx { domain_info }, + profile_partial: ProfilePartialView { menu_active_item: ProfileMenuItems::UserProfile, can_rw, diff --git a/server/core/static/style.css b/server/core/static/style.css index 345edecb4..a2dc18278 100644 --- a/server/core/static/style.css +++ b/server/core/static/style.css @@ -20,6 +20,15 @@ body { max-width: 680px; } +/* +* Bootstrap 5.3 fix for input-group validation +* :has checks that a child can be selected with the selector +* + selects the next sibling. +*/ +.was-validated .input-group:has(.form-control:invalid) + .invalid-feedback { + display: block !important; +} + /* * Sidebar */ diff --git a/server/core/templates/admin/admin_panel_template.html b/server/core/templates/admin/admin_panel_template.html new file mode 100644 index 000000000..165aaa3e6 --- /dev/null +++ b/server/core/templates/admin/admin_panel_template.html @@ -0,0 +1,10 @@ +(% extends "base_htmx_with_nav.html" %) + +(% block title %)Admin Panel(% endblock %) + +(% block head %) +(% endblock %) + +(% block main %) + (( partial|safe )) +(% endblock %) \ No newline at end of file diff --git a/server/core/templates/admin/admin_partial_base.html b/server/core/templates/admin/admin_partial_base.html new file mode 100644 index 000000000..2091c6f4f --- /dev/null +++ b/server/core/templates/admin/admin_partial_base.html @@ -0,0 +1,19 @@ +
+
+ +
+ (% block admin_page %) + (% endblock %) +
+
+
diff --git a/server/core/templates/admin/admin_person_details_partial.html b/server/core/templates/admin/admin_person_details_partial.html new file mode 100644 index 000000000..dacebec59 --- /dev/null +++ b/server/core/templates/admin/admin_person_details_partial.html @@ -0,0 +1,29 @@ +(% macro string_attr(dispname, name, value, editable, attribute) %) +(% if scim_effective_access.search.check(attribute|as_ref) %) +
+ +
+ +
+
+(% endif %) +(% endmacro %) + +
+ (% call string_attr("UUID", "uuid", person.uuid, false, Attribute::Uuid) %) + (% call string_attr("SPN", "spn", person.spn, false, Attribute::Spn) %) + (% call string_attr("Name", "name", person.name, true, Attribute::Name) %) + (% call string_attr("Displayname", "displayname", person.displayname, true, Attribute::DisplayName) %) + + (% if let Some(description) = person.description %) + (% call string_attr("Description", "description", description, true, Attribute::Description) %) + (% else %) + (% call string_attr("Description", "description", "none", true, Attribute::Description) %) + (% endif %) + + (% if let Some(entry_managed_by) = person.managed_by %) + (% call string_attr("Managed By", "managed_by", entry_managed_by.value, true, Attribute::EntryManagedBy) %) + (% else %) + (% call string_attr("Managed By", "managed_by", "none", true, Attribute::EntryManagedBy) %) + (% endif %) +
diff --git a/server/core/templates/admin/admin_person_view_partial.html b/server/core/templates/admin/admin_person_view_partial.html new file mode 100644 index 000000000..020e6ff96 --- /dev/null +++ b/server/core/templates/admin/admin_person_view_partial.html @@ -0,0 +1,57 @@ +(% extends "admin/admin_partial_base.html" %) + +(% block persons_item_extra_classes %)active(% endblock %) + +(% block admin_page %) + + +(% include "admin_person_details_partial.html" %) + +
+ +(% if scim_effective_access.search.check(Attribute::Mail|as_ref) %) + +
+ (% if person.mails.len() == 0 %) +

There are no email addresses associated with this person.

+ (% else %) +
    + (% for mail in person.mails %) +
  1. +
    (( mail.value ))
    +
    +
    +
  2. + (% endfor %) +
+ (% endif %) +
+(% endif %) + +(% if scim_effective_access.search.check(Attribute::DirectMemberOf|as_ref) %) + +
+ (% if person.groups.len() == 0 %) +

There are no groups this person is a direct member of.

+ (% else %) +
    + (% for group in person.groups %) +
  1. +
    (( group.value ))
    +
    +
    +
  2. + (% endfor %) +
+ (% endif %) +
+(% endif %) + + + +(% endblock %) \ No newline at end of file diff --git a/server/core/templates/admin/admin_persons_partial.html b/server/core/templates/admin/admin_persons_partial.html new file mode 100644 index 000000000..07a0e0237 --- /dev/null +++ b/server/core/templates/admin/admin_persons_partial.html @@ -0,0 +1,23 @@ +(% extends "admin/admin_partial_base.html" %) + +(% block persons_item_extra_classes %)active(% endblock %) + +(% block admin_page %) + + +
    + (% for (person, _) in persons %) +
  • +
    + (( person.name )) (( person.uuid )) +
    +
    +
    +
  • + (% endfor %) +
+(% endblock %) diff --git a/server/core/templates/admin/error_toast.html b/server/core/templates/admin/error_toast.html new file mode 100644 index 000000000..f85e8e129 --- /dev/null +++ b/server/core/templates/admin/error_toast.html @@ -0,0 +1,12 @@ +
+ +
\ No newline at end of file diff --git a/server/core/templates/admin/saved_toast.html b/server/core/templates/admin/saved_toast.html new file mode 100644 index 000000000..520e8c4dc --- /dev/null +++ b/server/core/templates/admin/saved_toast.html @@ -0,0 +1,11 @@ +
+ +
\ No newline at end of file diff --git a/server/core/templates/base_htmx_with_nav.html b/server/core/templates/base_htmx_with_nav.html index 2acdd7a5b..5bc306250 100644 --- a/server/core/templates/base_htmx_with_nav.html +++ b/server/core/templates/base_htmx_with_nav.html @@ -2,7 +2,9 @@ (% block body %) (% include "navbar.html" %) +
(% block main %)(% endblock %) +
(% include "signout_modal.html" %) (% endblock %) diff --git a/server/core/templates/navbar.html b/server/core/templates/navbar.html index 2f04c1abe..6162ce2d1 100644 --- a/server/core/templates/navbar.html +++ b/server/core/templates/navbar.html @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 7cdcea356..753885c9c 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -11,6 +11,7 @@ use crate::valueset::{ValueSet, ValueSetIutf8}; pub use kanidm_proto::attribute::Attribute; use kanidm_proto::constants::*; use kanidm_proto::internal::OperationError; +use kanidm_proto::scim_v1::JsonValue; use kanidm_proto::v1::AccountType; use uuid::Uuid; @@ -129,6 +130,12 @@ impl From for &'static str { } } +impl From for JsonValue { + fn from(value: EntryClass) -> Self { + Self::String(value.as_ref().to_string()) + } +} + impl AsRef for EntryClass { fn as_ref(&self) -> &str { self.into() diff --git a/server/lib/src/filter.rs b/server/lib/src/filter.rs index b9062e463..3f408f099 100644 --- a/server/lib/src/filter.rs +++ b/server/lib/src/filter.rs @@ -23,6 +23,7 @@ use hashbrown::HashMap; use hashbrown::HashSet; use kanidm_proto::constants::ATTR_UUID; use kanidm_proto::internal::{Filter as ProtoFilter, OperationError, SchemaError}; +use kanidm_proto::scim_v1::client::{AttrPath as ScimAttrPath, ScimFilter}; use ldap3_proto::proto::{LdapFilter, LdapSubstringFilter}; use serde::Deserialize; use uuid::Uuid; @@ -764,6 +765,21 @@ impl Filter { }, }) } + + #[instrument(name = "filter::from_scim_ro", level = "trace", skip_all)] + pub fn from_scim_ro( + ev: &Identity, + f: &ScimFilter, + qs: &mut QueryServerReadTransaction, + ) -> Result { + let depth = DEFAULT_LIMIT_FILTER_DEPTH_MAX as usize; + let mut elems = ev.limits().filter_max_elements; + Ok(Filter { + state: FilterInvalid { + inner: FilterComp::from_scim_ro(f, qs, depth, &mut elems)?, + }, + }) + } } impl FromStr for Filter { @@ -1087,32 +1103,21 @@ impl FilterComp { elems: &mut usize, ) -> Result { let ndepth = depth.checked_sub(1).ok_or(OperationError::ResourceLimit)?; + *elems = (*elems) + .checked_sub(1) + .ok_or(OperationError::ResourceLimit)?; Ok(match f { - LdapFilter::And(l) => { - *elems = (*elems) - .checked_sub(l.len()) - .ok_or(OperationError::ResourceLimit)?; - FilterComp::And( - l.iter() - .map(|f| Self::from_ldap_ro(f, qs, ndepth, elems)) - .collect::, _>>()?, - ) - } - LdapFilter::Or(l) => { - *elems = (*elems) - .checked_sub(l.len()) - .ok_or(OperationError::ResourceLimit)?; - - FilterComp::Or( - l.iter() - .map(|f| Self::from_ldap_ro(f, qs, ndepth, elems)) - .collect::, _>>()?, - ) - } + LdapFilter::And(l) => FilterComp::And( + l.iter() + .map(|f| Self::from_ldap_ro(f, qs, ndepth, elems)) + .collect::, _>>()?, + ), + LdapFilter::Or(l) => FilterComp::Or( + l.iter() + .map(|f| Self::from_ldap_ro(f, qs, ndepth, elems)) + .collect::, _>>()?, + ), LdapFilter::Not(l) => { - *elems = (*elems) - .checked_sub(1) - .ok_or(OperationError::ResourceLimit)?; FilterComp::AndNot(Box::new(Self::from_ldap_ro(l, qs, ndepth, elems)?)) } LdapFilter::Equality(a, v) => { @@ -1172,6 +1177,103 @@ impl FilterComp { } }) } + + fn from_scim_ro( + f: &ScimFilter, + qs: &mut QueryServerReadTransaction, + depth: usize, + elems: &mut usize, + ) -> Result { + let ndepth = depth.checked_sub(1).ok_or(OperationError::ResourceLimit)?; + *elems = (*elems) + .checked_sub(1) + .ok_or(OperationError::ResourceLimit)?; + Ok(match f { + ScimFilter::Present(ScimAttrPath { a, s: None }) => FilterComp::Pres(a.clone()), + ScimFilter::Equal(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + FilterComp::Eq(a.clone(), pv) + } + + ScimFilter::Contains(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + FilterComp::Cnt(a.clone(), pv) + } + ScimFilter::StartsWith(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + FilterComp::Stw(a.clone(), pv) + } + ScimFilter::EndsWith(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + FilterComp::Enw(a.clone(), pv) + } + ScimFilter::Greater(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + // Greater is equivalent to "not equal or less than". + FilterComp::And(vec![ + FilterComp::Pres(a.clone()), + FilterComp::AndNot(Box::new(FilterComp::Or(vec![ + FilterComp::LessThan(a.clone(), pv.clone()), + FilterComp::Eq(a.clone(), pv), + ]))), + ]) + } + ScimFilter::Less(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + FilterComp::LessThan(a.clone(), pv) + } + ScimFilter::GreaterOrEqual(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + // Greater or equal is equivalent to "not less than". + FilterComp::And(vec![ + FilterComp::Pres(a.clone()), + FilterComp::AndNot(Box::new(FilterComp::LessThan(a.clone(), pv.clone()))), + ]) + } + ScimFilter::LessOrEqual(ScimAttrPath { a, s: None }, json_value) => { + let pv = qs.resolve_scim_json_get(a, json_value)?; + FilterComp::Or(vec![ + FilterComp::LessThan(a.clone(), pv.clone()), + FilterComp::Eq(a.clone(), pv), + ]) + } + ScimFilter::Not(f) => { + let f = Self::from_scim_ro(f, qs, ndepth, elems)?; + FilterComp::AndNot(Box::new(f)) + } + ScimFilter::Or(left, right) => { + let left = Self::from_scim_ro(left, qs, ndepth, elems)?; + let right = Self::from_scim_ro(right, qs, ndepth, elems)?; + FilterComp::Or(vec![left, right]) + } + ScimFilter::And(left, right) => { + let left = Self::from_scim_ro(left, qs, ndepth, elems)?; + let right = Self::from_scim_ro(right, qs, ndepth, elems)?; + FilterComp::And(vec![left, right]) + } + ScimFilter::NotEqual(ScimAttrPath { s: None, .. }, _) => { + error!("Unsupported filter operation - not-equal"); + return Err(OperationError::FilterGeneration); + } + ScimFilter::Present(ScimAttrPath { s: Some(_), .. }) + | ScimFilter::Equal(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::NotEqual(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::Contains(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::StartsWith(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::EndsWith(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::Greater(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::Less(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::GreaterOrEqual(ScimAttrPath { s: Some(_), .. }, _) + | ScimFilter::LessOrEqual(ScimAttrPath { s: Some(_), .. }, _) => { + error!("Unsupported filter operation - sub-attribute"); + return Err(OperationError::FilterGeneration); + } + ScimFilter::Complex(..) => { + error!("Unsupported filter operation - complex"); + return Err(OperationError::FilterGeneration); + } + }) + } } /* We only configure partial eq if cfg test on the invalid/valid types */ diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index cdafef5b1..11f914ca6 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -35,6 +35,7 @@ use concread::arcache::{ARCacheBuilder, ARCacheReadTxn}; use concread::cowcell::*; use hashbrown::{HashMap, HashSet}; use kanidm_proto::internal::{DomainInfo as ProtoDomainInfo, ImageValue, UiHint}; +use kanidm_proto::scim_v1::client::ScimFilter; use kanidm_proto::scim_v1::server::ScimOAuth2ClaimMap; use kanidm_proto::scim_v1::server::ScimOAuth2ScopeMap; use kanidm_proto::scim_v1::server::ScimReference; @@ -934,6 +935,64 @@ pub trait QueryServerTransaction<'a> { } } + fn resolve_scim_json_get( + &mut self, + attr: &Attribute, + value: &JsonValue, + ) -> Result { + 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())); + }; + + match schema_a.syntax { + SyntaxType::Utf8String => { + let JsonValue::String(value) = value else { + return Err(OperationError::InvalidAttribute(attr.to_string())); + }; + Ok(PartialValue::Utf8(value.to_string())) + } + SyntaxType::Utf8StringInsensitive => { + let JsonValue::String(value) = value else { + return Err(OperationError::InvalidAttribute(attr.to_string())); + }; + Ok(PartialValue::new_iutf8(value)) + } + SyntaxType::Utf8StringIname => { + let JsonValue::String(value) = value else { + return Err(OperationError::InvalidAttribute(attr.to_string())); + }; + Ok(PartialValue::new_iname(value)) + } + SyntaxType::Uuid => { + let JsonValue::String(value) = value else { + return Err(OperationError::InvalidAttribute(attr.to_string())); + }; + + let un = self.name_to_uuid(value).unwrap_or(UUID_DOES_NOT_EXIST); + Ok(PartialValue::Uuid(un)) + } + SyntaxType::ReferenceUuid + | SyntaxType::OauthScopeMap + | SyntaxType::Session + | SyntaxType::ApiToken + | SyntaxType::Oauth2Session + | SyntaxType::ApplicationPassword => { + let JsonValue::String(value) = value else { + return Err(OperationError::InvalidAttribute(attr.to_string())); + }; + + let un = self.name_to_uuid(value).unwrap_or(UUID_DOES_NOT_EXIST); + Ok(PartialValue::Refer(un)) + } + + _ => Err(OperationError::InvalidAttribute(attr.to_string())), + } + } + fn resolve_scim_json_put( &mut self, attr: &Attribute, @@ -1555,6 +1614,40 @@ impl QueryServerReadTransaction<'_> { } } } + + #[instrument(level = "debug", skip_all)] + pub fn scim_search_ext( + &mut self, + ident: Identity, + filter: ScimFilter, + query: ScimEntryGetQuery, + ) -> Result, OperationError> { + let filter_intent = Filter::from_scim_ro(&ident, &filter, self)?; + + 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 r_attrs = query + .attributes + .map(|attr_set| attr_set.into_iter().collect()); + + let se = SearchEvent { + ident, + filter: f_valid, + filter_orig: f_intent_valid, + attrs: r_attrs, + effective_access_check: query.ext_access_check, + }; + + let vs = self.search_ext(&se)?; + + vs.into_iter() + .map(|entry| entry.to_scim_kanidm(self)) + .collect() + } } impl<'a> QueryServerTransaction<'a> for QueryServerWriteTransaction<'a> { @@ -2625,7 +2718,9 @@ impl<'a> QueryServerWriteTransaction<'a> { #[cfg(test)] mod tests { use crate::prelude::*; + use kanidm_proto::scim_v1::client::ScimFilter; use kanidm_proto::scim_v1::server::ScimReference; + use kanidm_proto::scim_v1::JsonValue; use kanidm_proto::scim_v1::ScimEntryGetQuery; #[qs_test] @@ -3077,4 +3172,44 @@ mod tests { assert!(ext_access_check.modify_present.check(&Attribute::Name)); assert!(ext_access_check.modify_remove.check(&Attribute::Name)); } + + #[qs_test] + async fn test_scim_basic_search_ext_query(server: &QueryServer) { + let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap(); + + let group_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)) + ); + + assert!(server_txn.internal_create(vec![e1]).is_ok()); + assert!(server_txn.commit().is_ok()); + + // Now read that entry. + let mut server_txn = server.read().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); + + let filter = ScimFilter::And( + Box::new(ScimFilter::Equal( + Attribute::Class.into(), + EntryClass::Group.into(), + )), + Box::new(ScimFilter::Equal( + Attribute::Uuid.into(), + JsonValue::String(group_uuid.to_string()), + )), + ); + + let base: Vec = server_txn + .scim_search_ext(idm_admin_ident, filter, ScimEntryGetQuery::default()) + .unwrap(); + + assert_eq!(base.len(), 1); + assert_eq!(base[0].header.id, group_uuid); + } }