diff --git a/proto/src/attribute.rs b/proto/src/attribute.rs index 2e9556e35..9e25c103b 100644 --- a/proto/src/attribute.rs +++ b/proto/src/attribute.rs @@ -140,6 +140,9 @@ pub enum Attribute { Refers, Replicated, Rs256PrivateKeyDer, + /// A set of scim schemas. This is similar to a kanidm class. + #[serde(rename = "schemas")] + ScimSchemas, Scope, SourceUuid, Spn, @@ -368,6 +371,7 @@ impl Attribute { Attribute::Replicated => ATTR_REPLICATED, Attribute::Rs256PrivateKeyDer => ATTR_RS256_PRIVATE_KEY_DER, Attribute::Scope => ATTR_SCOPE, + Attribute::ScimSchemas => ATTR_SCIM_SCHEMAS, Attribute::SourceUuid => ATTR_SOURCE_UUID, Attribute::Spn => ATTR_SPN, Attribute::SshPublicKey => ATTR_SSH_PUBLICKEY, @@ -548,6 +552,7 @@ impl Attribute { ATTR_REFERS => Attribute::Refers, ATTR_REPLICATED => Attribute::Replicated, ATTR_RS256_PRIVATE_KEY_DER => Attribute::Rs256PrivateKeyDer, + ATTR_SCIM_SCHEMAS => Attribute::ScimSchemas, ATTR_SCOPE => Attribute::Scope, ATTR_SOURCE_UUID => Attribute::SourceUuid, ATTR_SPN => Attribute::Spn, diff --git a/proto/src/constants.rs b/proto/src/constants.rs index b77e7126d..b1ed216a9 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -179,6 +179,7 @@ pub const ATTR_RECYCLEDDIRECTMEMBEROF: &str = "recycled_directmemberof"; pub const ATTR_REFERS: &str = "refers"; pub const ATTR_REPLICATED: &str = "replicated"; pub const ATTR_RS256_PRIVATE_KEY_DER: &str = "rs256_private_key_der"; +pub const ATTR_SCIM_SCHEMAS: &str = "schemas"; pub const ATTR_SCOPE: &str = "scope"; pub const ATTR_SELF: &str = "self"; pub const ATTR_SOURCE_UUID: &str = "source_uuid"; diff --git a/proto/src/internal/error.rs b/proto/src/internal/error.rs index 5e3d66854..2e2b35872 100644 --- a/proto/src/internal/error.rs +++ b/proto/src/internal/error.rs @@ -148,7 +148,6 @@ pub enum OperationError { KG002TaskCommFailure, KG003CacheClearFailed, - // What about something like this for unique errors? // Credential Update Errors CU0001WebauthnAttestationNotTrusted, CU0002WebauthnRegistrationError, @@ -173,6 +172,31 @@ pub enum OperationError { // SCIM SC0001IncomingSshPublicKey, + SC0002ReferenceSyntaxInvalid, + SC0003MailSyntaxInvalid, + SC0004UuidSyntaxInvalid, + SC0005BoolSyntaxInvalid, + SC0006Uint32SyntaxInvalid, + SC0007UrlSyntaxInvalid, + SC0008SyntaxTypeSyntaxInvalid, + SC0009IndexTypeSyntaxInvalid, + SC0010DateTimeSyntaxInvalid, + SC0011AddressSyntaxInvalid, + SC0012CertificateSyntaxInvalid, + SC0013CertificateInvalidDer, + SC0014CertificateInvalidDigest, + SC0015CredentialTypeSyntaxInvalid, + SC0016InameSyntaxInvalid, + SC0017Iutf8SyntaxInvalid, + SC0018NsUniqueIdSyntaxInvalid, + SC0019Oauth2ScopeSyntaxInvalid, + SC0020Oauth2ScopeMapSyntaxInvalid, + SC0021Oauth2ScopeMapMissingGroupIdentifier, + SC0022Oauth2ClaimMapSyntaxInvalid, + SC0023Oauth2ClaimMapMissingGroupIdentifier, + SC0024SshPublicKeySyntaxInvalid, + SC0025UiHintSyntaxInvalid, + SC0026Utf8SyntaxInvalid, // Migration MG0001InvalidReMigrationLevel, MG0002RaiseDomainLevelExceedsMaximum, @@ -409,6 +433,33 @@ impl OperationError { Self::MG0008SkipUpgradeAttempted => Some("Skip Upgrade Attempted.".into()), Self::PL0001GidOverlapsSystemRange => None, Self::SC0001IncomingSshPublicKey => None, + Self::SC0002ReferenceSyntaxInvalid => Some("A SCIM Reference Set contained invalid syntax and can not be processed.".into()), + Self::SC0003MailSyntaxInvalid => Some("A SCIM Mail Address contained invalid syntax".into()), + Self::SC0004UuidSyntaxInvalid => Some("A SCIM Uuid contained invalid syntax".into()), + Self::SC0005BoolSyntaxInvalid => Some("A SCIM boolean contained invalid syntax".into()), + Self::SC0006Uint32SyntaxInvalid => Some("A SCIM Uint32 contained invalid syntax".into()), + Self::SC0007UrlSyntaxInvalid => Some("A SCIM Url contained invalid syntax".into()), + Self::SC0008SyntaxTypeSyntaxInvalid => Some("A SCIM SyntaxType contained invalid syntax".into()), + Self::SC0009IndexTypeSyntaxInvalid => Some("A SCIM IndexType contained invalid syntax".into()), + Self::SC0010DateTimeSyntaxInvalid => Some("A SCIM DateTime contained invalid syntax".into()), + + Self::SC0011AddressSyntaxInvalid => Some("A SCIM Address contained invalid syntax".into()), + Self::SC0012CertificateSyntaxInvalid => Some("A SCIM Certificate contained invalid binary data".into()), + Self::SC0013CertificateInvalidDer => Some("A SCIM Certificate did not contain valid DER".into()), + Self::SC0014CertificateInvalidDigest => Some("A SCIM Certificate was unable to be digested".into()), + Self::SC0015CredentialTypeSyntaxInvalid => Some("A SCIM CredentialType contained invalid syntax".into()), + Self::SC0016InameSyntaxInvalid => Some("A SCIM Iname string contained invalid syntax".into()), + Self::SC0017Iutf8SyntaxInvalid => Some("A SCIM Iutf8 string contained invalid syntax".into()), + Self::SC0018NsUniqueIdSyntaxInvalid => Some("A SCIM NsUniqueID contained invalid syntax".into()), + Self::SC0019Oauth2ScopeSyntaxInvalid => Some("A SCIM Oauth2 Scope contained invalid syntax".into()), + Self::SC0020Oauth2ScopeMapSyntaxInvalid => Some("A SCIM Oauth2 Scope Map contained invalid syntax".into()), + Self::SC0021Oauth2ScopeMapMissingGroupIdentifier => Some("A SCIM Oauth2 Scope Map was missing a group name or uuid".into()), + Self::SC0022Oauth2ClaimMapSyntaxInvalid => Some("A SCIM Oauth2 Claim Map contained invalid syntax".into()), + Self::SC0023Oauth2ClaimMapMissingGroupIdentifier => Some("A SCIM Claim Map was missing a group name or uuid".into()), + Self::SC0024SshPublicKeySyntaxInvalid => Some("A SCIM Ssh Public Key contained invalid syntax".into()), + Self::SC0025UiHintSyntaxInvalid => Some("A SCIM UiHint contained invalid syntax".into()), + Self::SC0026Utf8SyntaxInvalid => Some("A SCIM Utf8 String Scope Map contained invalid syntax".into()), + Self::UI0001ChallengeSerialisation => Some("The WebAuthn challenge was unable to be serialised.".into()), Self::UI0002InvalidState => Some("The credential update process returned an invalid state transition.".into()), Self::VL0001ValueSshPublicKeyString => None, diff --git a/proto/src/oauth2.rs b/proto/src/oauth2.rs index 0a22fb6e2..db5ed9ac5 100644 --- a/proto/src/oauth2.rs +++ b/proto/src/oauth2.rs @@ -33,6 +33,7 @@ pub struct PkceRequest { /// An OAuth2 client redirects to the authorisation server with Authorisation Request /// parameters. +#[serde_as] #[skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AuthorisationRequest { @@ -43,7 +44,8 @@ pub struct AuthorisationRequest { #[serde(flatten)] pub pkce_request: Option, pub redirect_uri: Url, - pub scope: String, + #[serde_as(as = "StringWithSeparator::")] + pub scope: BTreeSet, // OIDC adds a nonce parameter that is optional. pub nonce: Option, // OIDC also allows other optional params @@ -185,6 +187,7 @@ pub struct OAuth2RFC9068TokenExtensions { } /// The response for an access token +#[serde_as] #[skip_serializing_none] #[derive(Serialize, Deserialize, Debug)] pub struct AccessTokenResponse { @@ -195,7 +198,8 @@ pub struct AccessTokenResponse { pub refresh_token: Option, /// Space separated list of scopes that were approved, if this differs from the /// original request. - pub scope: Option, + #[serde_as(as = "StringWithSeparator::")] + pub scope: BTreeSet, /// If the `openid` scope was requested, an `id_token` may be present in the response. pub id_token: Option, } @@ -248,11 +252,13 @@ pub struct AccessTokenIntrospectRequest { /// Response to an introspection request. If the token is inactive or revoked, only /// `active` will be set to the value of `false`. +#[serde_as] #[skip_serializing_none] #[derive(Serialize, Deserialize, Debug)] pub struct AccessTokenIntrospectResponse { pub active: bool, - pub scope: Option, + #[serde_as(as = "StringWithSeparator::")] + pub scope: BTreeSet, pub client_id: Option, pub username: Option, pub token_type: Option, @@ -269,7 +275,7 @@ impl AccessTokenIntrospectResponse { pub fn inactive() -> Self { AccessTokenIntrospectResponse { active: false, - scope: None, + scope: BTreeSet::default(), client_id: None, username: None, token_type: None, diff --git a/proto/src/scim_v1/client.rs b/proto/src/scim_v1/client.rs index 397f25b75..07108b7b1 100644 --- a/proto/src/scim_v1/client.rs +++ b/proto/src/scim_v1/client.rs @@ -1,11 +1,124 @@ +//! These are types that a client will send to the server. +use super::ScimOauth2ClaimMapJoinChar; +use crate::attribute::Attribute; use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use serde_with::formats::PreferMany; +use serde_with::OneOrMany; +use serde_with::{base64, formats, serde_as, skip_serializing_none}; use sshkey_attest::proto::PublicKey as SshPublicKey; +use std::collections::{BTreeMap, BTreeSet}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; +use uuid::Uuid; pub type ScimSshPublicKeys = Vec; #[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct ScimSshPublicKey { pub label: String, pub value: SshPublicKey, } + +#[serde_as] +#[skip_serializing_none] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ScimReference { + pub uuid: Option, + pub value: Option, +} + +pub type ScimReferences = Vec; + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(transparent)] +pub struct ScimDateTime { + #[serde_as(as = "Rfc3339")] + pub date_time: OffsetDateTime, +} + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ScimCertificate { + #[serde_as(as = "base64::Base64")] + pub der: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ScimAddress { + pub street_address: String, + pub locality: String, + pub region: String, + pub postal_code: String, + pub country: String, +} + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScimOAuth2ClaimMap { + pub group: Option, + pub group_uuid: Option, + pub claim: String, + pub join_char: ScimOauth2ClaimMapJoinChar, + pub values: BTreeSet, +} + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScimOAuth2ScopeMap { + pub group: Option, + pub group_uuid: Option, + pub scopes: BTreeSet, +} + +#[derive(Serialize, Debug, Clone)] +pub struct ScimEntryPutKanidm { + pub id: Uuid, + #[serde(flatten)] + pub attrs: BTreeMap>, +} + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct ScimStrings(#[serde_as(as = "OneOrMany<_, PreferMany>")] pub Vec); + +#[derive(Debug, Clone, Deserialize)] +pub struct ScimEntryPutGeneric { + // id is only used to target the entry in question + pub id: Uuid, + // external_id can't be set by put + // meta is skipped on put + // Schemas are decoded as part of "attrs". + /// Update an attribute to contain the following value state. + /// If the attribute is None, it is removed. + #[serde(flatten)] + pub attrs: BTreeMap>, +} + +impl TryFrom for ScimEntryPutGeneric { + type Error = serde_json::Error; + + fn try_from(value: ScimEntryPutKanidm) -> Result { + let ScimEntryPutKanidm { id, attrs } = value; + + let attrs = attrs + .into_iter() + .map(|(attr, value)| { + if let Some(v) = value { + serde_json::to_value(v).map(|json_value| (attr, Some(json_value))) + } else { + Ok((attr, None)) + } + }) + .collect::>()?; + + Ok(ScimEntryPutGeneric { id, attrs }) + } +} diff --git a/proto/src/scim_v1/mod.rs b/proto/src/scim_v1/mod.rs index ac17bce53..93333e69f 100644 --- a/proto/src/scim_v1/mod.rs +++ b/proto/src/scim_v1/mod.rs @@ -18,7 +18,7 @@ use crate::attribute::Attribute; use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; +use sshkey_attest::proto::PublicKey as SshPublicKey; use std::collections::BTreeMap; use utoipa::ToSchema; @@ -27,6 +27,7 @@ use serde_with::{serde_as, skip_serializing_none, StringWithSeparator}; pub use self::synch::*; pub use scim_proto::prelude::*; +pub use serde_json::Value as JsonValue; pub mod client; pub mod server; @@ -52,6 +53,46 @@ pub struct ScimEntryGetQuery { pub attributes: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub enum ScimSchema { + #[serde(rename = "urn:ietf:params:scim:schemas:kanidm:sync:1:account")] + SyncAccountV1, + #[serde(rename = "urn:ietf:params:scim:schemas:kanidm:sync:1:group")] + SyncV1GroupV1, + #[serde(rename = "urn:ietf:params:scim:schemas:kanidm:sync:1:person")] + SyncV1PersonV1, + #[serde(rename = "urn:ietf:params:scim:schemas:kanidm:sync:1:posixaccount")] + SyncV1PosixAccountV1, + #[serde(rename = "urn:ietf:params:scim:schemas:kanidm:sync:1:posixgroup")] + SyncV1PosixGroupV1, +} + +#[serde_as] +#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ScimMail { + #[serde(default)] + pub primary: bool, + pub value: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ScimSshPublicKey { + pub label: String, + pub value: SshPublicKey, +} + +#[derive(Deserialize, Serialize, Debug, Clone, ToSchema)] +pub enum ScimOauth2ClaimMapJoinChar { + #[serde(rename = ",", alias = "csv")] + CommaSeparatedValue, + #[serde(rename = " ", alias = "ssv")] + SpaceSeparatedValue, + #[serde(rename = ";", alias = "json_array")] + JsonArray, +} + #[cfg(test)] mod tests { // use super::*; diff --git a/proto/src/scim_v1/server.rs b/proto/src/scim_v1/server.rs index 0034e6ef6..4c61cdff7 100644 --- a/proto/src/scim_v1/server.rs +++ b/proto/src/scim_v1/server.rs @@ -1,8 +1,11 @@ +use super::ScimMail; +use super::ScimOauth2ClaimMapJoinChar; +use super::ScimSshPublicKey; use crate::attribute::Attribute; +use crate::internal::UiHint; use scim_proto::ScimEntryHeader; use serde::Serialize; -use serde_with::{base64, formats, hex::Hex, serde_as, skip_serializing_none, StringWithSeparator}; -use sshkey_attest::proto::PublicKey as SshPublicKey; +use serde_with::{base64, formats, hex::Hex, serde_as, skip_serializing_none}; use std::collections::{BTreeMap, BTreeSet}; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; @@ -32,13 +35,6 @@ pub struct ScimAddress { pub country: String, } -#[derive(Serialize, Debug, Clone, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ScimMail { - pub primary: bool, - pub value: String, -} - #[derive(Serialize, Debug, Clone, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ScimApplicationPassword { @@ -75,13 +71,6 @@ pub struct ScimAuditString { pub value: String, } -#[derive(Serialize, Debug, Clone, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ScimSshPublicKey { - pub label: String, - pub value: SshPublicKey, -} - #[derive(Serialize, Debug, Clone, ToSchema)] #[serde(rename_all = "camelCase")] pub enum ScimIntentTokenState { @@ -164,8 +153,8 @@ pub struct ScimApiToken { #[derive(Serialize, Debug, Clone, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ScimOAuth2ScopeMap { - pub uuid: Uuid, - #[serde_as(as = "StringWithSeparator::")] + pub group: String, + pub group_uuid: Uuid, pub scopes: BTreeSet, } @@ -173,14 +162,13 @@ pub struct ScimOAuth2ScopeMap { #[derive(Serialize, Debug, Clone, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ScimOAuth2ClaimMap { - pub group: Uuid, + pub group: String, + pub group_uuid: Uuid, pub claim: String, - pub join_char: String, - #[serde_as(as = "StringWithSeparator::")] + pub join_char: ScimOauth2ClaimMapJoinChar, pub values: BTreeSet, } -#[serde_as] #[derive(Serialize, Debug, Clone, PartialEq, Eq, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ScimReference { @@ -226,6 +214,7 @@ pub enum ScimValueKanidm { OAuth2ScopeMap(Vec), OAuth2ClaimMap(Vec), KeyInternal(Vec), + UiHints(Vec), } impl From for ScimValueKanidm { @@ -240,6 +229,12 @@ impl From for ScimValueKanidm { } } +impl From> for ScimValueKanidm { + fn from(set: Vec) -> Self { + Self::UiHints(set) + } +} + impl From> for ScimValueKanidm { fn from(set: Vec) -> Self { Self::ArrayDateTime(set) @@ -252,6 +247,12 @@ impl From for ScimValueKanidm { } } +impl From<&str> for ScimValueKanidm { + fn from(s: &str) -> Self { + Self::String(s.to_string()) + } +} + impl From> for ScimValueKanidm { fn from(set: Vec) -> Self { Self::ArrayString(set) diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 9c0f9994c..7cdcea356 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -7,6 +7,7 @@ use crate::entry::{Entry, EntryInit, EntryInitNew, EntryNew}; use crate::idm::account::Account; use crate::value::PartialValue; use crate::value::Value; +use crate::valueset::{ValueSet, ValueSetIutf8}; pub use kanidm_proto::attribute::Attribute; use kanidm_proto::constants::*; use kanidm_proto::internal::OperationError; @@ -178,6 +179,11 @@ impl EntryClass { Value::new_iutf8(s) } + pub fn to_valueset(self) -> ValueSet { + let s: &'static str = self.into(); + ValueSetIutf8::new(s) + } + pub fn to_partialvalue(self) -> PartialValue { let s: &'static str = self.into(); PartialValue::new_iutf8(s) diff --git a/server/lib/src/entry.rs b/server/lib/src/entry.rs index f98c5e5e1..452c4f046 100644 --- a/server/lib/src/entry.rs +++ b/server/lib/src/entry.rs @@ -2253,10 +2253,13 @@ impl Entry { Ok(ProtoEntry { attrs: attrs? }) } - pub fn to_scim_kanidm( + pub fn to_scim_kanidm<'a, TXN>( &self, - read_txn: &mut QueryServerReadTransaction, - ) -> Result { + read_txn: &mut TXN, + ) -> Result + where + TXN: QueryServerTransaction<'a>, + { let result: Result, OperationError> = self .attrs .iter() @@ -3199,6 +3202,7 @@ where error!("Modification assertion was not met. {} {:?}", attr, value); })?; } + Modify::Set(attr, valueset) => self.set_ava_set(attr, valueset.clone()), } } Ok(()) diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index e3701b160..e3613729c 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -53,7 +53,6 @@ use crate::idm::server::{ IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction, IdmServerTransaction, }; use crate::prelude::*; -use crate::utils::str_join; use crate::value::{Oauth2Session, OauthClaimMapJoin, SessionState, OAUTHSCOPE_RE}; #[derive(Serialize, Deserialize, Debug, PartialEq)] @@ -1475,11 +1474,7 @@ impl IdmServerProxyWriteTransaction<'_> { let session_id = Uuid::new_v4(); - let scope = if granted_scopes.is_empty() { - None - } else { - Some(str_join(&granted_scopes)) - }; + let scope = granted_scopes.clone(); let uuid = o2rs.uuid; @@ -1564,11 +1559,7 @@ impl IdmServerProxyWriteTransaction<'_> { let refresh_expiry = iat + OAUTH_REFRESH_TOKEN_EXPIRY as i64; let odt_refresh_expiry = odt_ct + Duration::from_secs(OAUTH_REFRESH_TOKEN_EXPIRY); - let scope = if scopes.is_empty() { - None - } else { - Some(str_join(&scopes)) - }; + let scope = scopes.clone(); let iss = o2rs.iss.clone(); @@ -1937,11 +1928,7 @@ impl IdmServerProxyReadTransaction<'_> { } // scopes - you need to have every requested scope or this auth_req is denied. - let req_scopes: BTreeSet = auth_req - .scope - .split_ascii_whitespace() - .map(str::to_string) - .collect(); + let req_scopes: BTreeSet = auth_req.scope.clone(); if req_scopes.is_empty() { admin_error!("Invalid OAuth2 request - must contain at least one requested scope"); @@ -2273,11 +2260,7 @@ impl IdmServerProxyReadTransaction<'_> { // ==== good to generate response ==== - let scope = if scopes.is_empty() { - None - } else { - Some(str_join(&scopes)) - }; + let scope = scopes.clone(); let preferred_username = if prefer_short_username { Some(account.name.clone()) @@ -2343,11 +2326,7 @@ impl IdmServerProxyReadTransaction<'_> { return Ok(AccessTokenIntrospectResponse::inactive()); }; - let scope = if scopes.is_empty() { - None - } else { - Some(str_join(&scopes)) - }; + let scope = scopes.clone(); let token_type = Some(AccessTokenType::Bearer); @@ -2904,6 +2883,7 @@ fn check_is_loopback(redirect_uri: &Url) -> bool { #[cfg(test)] mod tests { use base64::{engine::general_purpose, Engine as _}; + use std::collections::BTreeSet; use std::convert::TryFrom; use std::str::FromStr; use std::time::Duration; @@ -2953,6 +2933,8 @@ mod tests { $code_challenge:expr, $scope:expr ) => {{ + let scope: BTreeSet = $scope.split(" ").map(|s| s.to_string()).collect(); + let auth_req = AuthorisationRequest { response_type: "code".to_string(), client_id: "test_resource_server".to_string(), @@ -2962,7 +2944,7 @@ mod tests { code_challenge_method: CodeChallengeMethod::S256, }), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: $scope, + scope, nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -3454,7 +3436,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3475,7 +3457,7 @@ mod tests { state: "123".to_string(), pkce_request: None, redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3496,7 +3478,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3517,7 +3499,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://totes.not.sus.org/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3538,7 +3520,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://demo.example.com/oauth2/wrong_place").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3559,7 +3541,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3582,7 +3564,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: "invalid_scope read".to_string(), + scope: btreeset!["invalid_scope".to_string(), "read".to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3603,7 +3585,7 @@ mod tests { state: "123".to_string(), pkce_request: pkce_request.clone(), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: "read openid".to_string(), + scope: btreeset!["openid".to_string(), "read".to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3624,7 +3606,7 @@ mod tests { state: "123".to_string(), pkce_request, redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: "read openid".to_string(), + scope: btreeset!["openid".to_string(), "read".to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -3918,7 +3900,7 @@ mod tests { code_challenge_method: CodeChallengeMethod::S256, }), redirect_uri: Url::parse("https://portal.example.com").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_GROUPS.to_string()], nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -3988,7 +3970,7 @@ mod tests { code_challenge_method: CodeChallengeMethod::S256, }), redirect_uri: Url::parse("app://cheese").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_GROUPS.to_string()], nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -4092,7 +4074,10 @@ mod tests { eprintln!("👉 {intr_response:?}"); assert!(intr_response.active); - assert_eq!(intr_response.scope.as_deref(), Some("openid supplement")); + assert_eq!( + intr_response.scope, + btreeset!["openid".to_string(), "supplement".to_string()] + ); assert_eq!( intr_response.client_id.as_deref(), Some("test_resource_server") @@ -5191,7 +5176,7 @@ mod tests { state: "123".to_string(), pkce_request: None, redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_GROUPS.to_string()], nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -5405,7 +5390,7 @@ mod tests { code_challenge_method: CodeChallengeMethod::S256, }), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: "openid email".to_string(), + scope: btreeset!["openid".to_string(), "email".to_string()], nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -5464,7 +5449,7 @@ mod tests { }), redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), // Note the scope isn't requested here! - scope: "openid email".to_string(), + scope: btreeset!["openid".to_string(), "email".to_string()], nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -5602,7 +5587,7 @@ mod tests { state: "123".to_string(), pkce_request: None, redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -5680,7 +5665,7 @@ mod tests { code_challenge_method: CodeChallengeMethod::S256, }), redirect_uri: Url::parse("http://demo.example.com/oauth2/result").unwrap(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: None, oidc_ext: Default::default(), max_age: None, @@ -6581,7 +6566,10 @@ mod tests { eprintln!("👉 {intr_response:?}"); assert!(intr_response.active); - assert_eq!(intr_response.scope.as_deref(), Some("openid supplement")); + assert_eq!( + intr_response.scope, + btreeset!["openid".to_string(), "supplement".to_string()] + ); assert_eq!( intr_response.client_id.as_deref(), Some("test_resource_server") @@ -6640,8 +6628,8 @@ mod tests { code_challenge, code_challenge_method: CodeChallengeMethod::S256, }), - redirect_uri: redirect_uri.clone(), - scope: OAUTH2_SCOPE_OPENID.to_string(), + redirect_uri: Url::parse("http://localhost:8765/oauth2/result").unwrap(), + scope: btreeset![OAUTH2_SCOPE_OPENID.to_string()], nonce: Some("abcdef".to_string()), oidc_ext: Default::default(), max_age: None, @@ -6731,7 +6719,7 @@ mod tests { eprintln!("👉 {intr_response:?}"); assert!(intr_response.active); - assert_eq!(intr_response.scope.as_deref(), Some("supplement")); + assert_eq!(intr_response.scope, btreeset!["supplement".to_string()]); assert_eq!( intr_response.client_id.as_deref(), Some("test_resource_server") diff --git a/server/lib/src/modify.rs b/server/lib/src/modify.rs index 9a8807bc1..2c4e96a46 100644 --- a/server/lib/src/modify.rs +++ b/server/lib/src/modify.rs @@ -10,6 +10,7 @@ use kanidm_proto::internal::{ use kanidm_proto::v1::Entry as ProtoEntry; // Should this be std? use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use crate::prelude::*; use crate::schema::SchemaTransaction; @@ -23,16 +24,17 @@ pub struct ModifyInvalid; #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum Modify { - // This value *should* exist. - // Clippy doesn't like value here, as value > pv. It could be an improvement to - // box here, but not sure. ... TODO and thought needed. + /// This value *should* exist for this attribute. Present(Attribute, Value), - // This value *should not* exist. + /// This value *should not* exist for this attribute. Removed(Attribute, PartialValue), - // This attr *should not* exist. + /// This attr should not exist, and if it does exist, will have all content removed. Purged(Attribute), - // This attr and value must exist *in this state* for this change to proceed. + /// This attr and value must exist *in this state* for this change to proceed. Assert(Attribute, PartialValue), + /// Set and replace the entire content of an attribute. This requires both presence + /// and removal access to the attribute to proceed. + Set(Attribute, ValueSet), } pub fn m_pres(attr: Attribute, v: &Value) -> Modify { @@ -201,6 +203,10 @@ impl ModifyList { Some(_attr_name) => Ok(Modify::Purged(attr.clone())), None => Err(SchemaError::InvalidAttribute(attr.to_string())), }, + Modify::Set(attr, valueset) => match schema_attributes.get(attr) { + Some(_attr_name) => Ok(Modify::Set(attr.clone(), valueset.clone())), + None => Err(SchemaError::InvalidAttribute(attr.to_string())), + }, }) .collect(); @@ -227,6 +233,26 @@ impl ModifyList { } } +impl From>> for ModifyList { + fn from(attrs: BTreeMap>) -> Self { + let mods = attrs + .into_iter() + .map(|(attr, maybe_valueset)| { + if let Some(valueset) = maybe_valueset { + Modify::Set(attr, valueset) + } else { + Modify::Purged(attr) + } + }) + .collect(); + + ModifyList { + valid: ModifyInvalid, + mods, + } + } +} + impl ModifyList { /// ⚠️ - Create a new modlist that is considered valid, bypassing schema. /// This is a TEST ONLY method and will never be exposed in production. diff --git a/server/lib/src/plugins/base.rs b/server/lib/src/plugins/base.rs index bcca88b20..48d8dcd15 100644 --- a/server/lib/src/plugins/base.rs +++ b/server/lib/src/plugins/base.rs @@ -164,9 +164,10 @@ impl Plugin for Base { ) -> Result<(), OperationError> { me.modlist.iter().try_for_each(|modify| { let attr = match &modify { - Modify::Present(a, _) => Some(a), - Modify::Removed(a, _) => Some(a), - Modify::Purged(a) => Some(a), + Modify::Present(a, _) + | Modify::Removed(a, _) + | Modify::Purged(a) + | Modify::Set(a, _) => Some(a), Modify::Assert(_, _) => None, }; if attr == Some(&Attribute::Uuid) { @@ -191,9 +192,10 @@ impl Plugin for Base { .flat_map(|ml| ml.iter()) .try_for_each(|modify| { let attr = match &modify { - Modify::Present(a, _) => Some(a), - Modify::Removed(a, _) => Some(a), - Modify::Purged(a) => Some(a), + Modify::Present(a, _) + | Modify::Removed(a, _) + | Modify::Set(a, _) + | Modify::Purged(a) => Some(a), Modify::Assert(_, _) => None, }; if attr == Some(&Attribute::Uuid) { diff --git a/server/lib/src/plugins/protected.rs b/server/lib/src/plugins/protected.rs index b30232b6a..3c8dfeec4 100644 --- a/server/lib/src/plugins/protected.rs +++ b/server/lib/src/plugins/protected.rs @@ -146,7 +146,10 @@ impl Plugin for Protected { me.modlist.into_iter().try_fold((), |(), m| { // Already hit an error, move on. let a = match m { - Modify::Present(a, _) | Modify::Removed(a, _) | Modify::Purged(a) => Some(a), + Modify::Present(a, _) + | Modify::Removed(a, _) + | Modify::Set(a, _) + | Modify::Purged(a) => Some(a), Modify::Assert(_, _) => None, }; if let Some(attr) = a { @@ -225,7 +228,7 @@ impl Plugin for Protected { .try_fold((), |(), m| { // Already hit an error, move on. let a = match m { - Modify::Present(a, _) | Modify::Removed(a, _) | Modify::Purged(a) => Some(a), + Modify::Present(a, _) | Modify::Removed(a, _) | Modify::Set(a, _) | Modify::Purged(a) => Some(a), Modify::Assert(_, _) => None, }; if let Some(attr) = a { diff --git a/server/lib/src/server/access/mod.rs b/server/lib/src/server/access/mod.rs index 564b52c53..212b18c98 100644 --- a/server/lib/src/server/access/mod.rs +++ b/server/lib/src/server/access/mod.rs @@ -345,9 +345,13 @@ pub trait AccessControlsTransaction<'a> { .into_iter() .filter_map(|e| { match apply_search_access(&se.ident, related_acp.as_slice(), &e) { - SearchResult::Denied | SearchResult::Grant => { + SearchResult::Denied => { + None + } + SearchResult::Grant => { // No properly written access module should allow // unbounded attribute read! + error!("An access module allowed full read, this is a BUG! Denying read to prevent data leaks."); None } SearchResult::Allow(allowed_attrs) => { @@ -450,8 +454,8 @@ pub trait AccessControlsTransaction<'a> { .modlist .iter() .filter_map(|m| match m { - Modify::Present(a, _) => Some(a.clone()), - _ => None, + Modify::Present(a, _) | Modify::Set(a, _) => Some(a.clone()), + Modify::Removed(..) | Modify::Assert(..) | Modify::Purged(_) => None, }) .collect(); @@ -459,19 +463,18 @@ pub trait AccessControlsTransaction<'a> { .modlist .iter() .filter_map(|m| match m { - Modify::Removed(a, _) => Some(a.clone()), - Modify::Purged(a) => Some(a.clone()), - _ => None, + Modify::Set(a, _) | Modify::Removed(a, _) | Modify::Purged(a) => Some(a.clone()), + Modify::Present(..) | Modify::Assert(..) => None, }) .collect(); // Build the set of classes that we to work on, only in terms of "addition". To remove // I think we have no limit, but ... william of the future may find a problem with this // policy. - let requested_classes: BTreeSet<&str> = me - .modlist - .iter() - .filter_map(|m| match m { + let mut requested_classes: BTreeSet<&str> = Default::default(); + + for modify in me.modlist.iter() { + match modify { Modify::Present(a, v) => { if a == Attribute::Class.as_ref() { // Here we have an option<&str> which could mean there is a risk of @@ -480,21 +483,23 @@ pub trait AccessControlsTransaction<'a> { // existence, and second, we would have failed the mod at schema checking // earlier in the process as these were not correctly type. As a result // we can trust these to be correct here and not to be "None". - v.to_str() - } else { - None + requested_classes.extend(v.to_str()) } } Modify::Removed(a, v) => { if a == Attribute::Class.as_ref() { - v.to_str() - } else { - None + requested_classes.extend(v.to_str()) } } - _ => None, - }) - .collect(); + Modify::Set(a, v) => { + if a == Attribute::Class.as_ref() { + // flatten to remove the option down to an iterator + requested_classes.extend(v.as_iutf8_iter().into_iter().flatten()) + } + } + _ => {} + } + } debug!(?requested_pres, "Requested present set"); debug!(?requested_rem, "Requested remove set"); @@ -1081,6 +1086,7 @@ mod tests { Access, AccessClass, AccessControls, AccessControlsTransaction, AccessEffectivePermission, }; use crate::prelude::*; + use crate::valueset::ValueSetIname; const UUID_TEST_ACCOUNT_1: Uuid = uuid::uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930"); const UUID_TEST_ACCOUNT_2: Uuid = uuid::uuid!("cec0852a-abdf-4ea6-9dae-d3157cb33d3a"); @@ -1948,7 +1954,7 @@ mod tests { debug!("result --> {:?}", res); debug!("expect --> {:?}", $expect); // should be ok, and same as expect. - assert_eq!(res, $expect); + assert_eq!($expect, res); }}; ( $me:expr, @@ -1975,12 +1981,14 @@ mod tests { debug!("result --> {:?}", res); debug!("expect --> {:?}", $expect); // should be ok, and same as expect. - assert_eq!(res, $expect); + assert_eq!($expect, res); }}; } #[test] fn test_access_enforce_modify() { + sketching::test_init(); + let ev1 = E_TESTPERSON_1.clone().into_sealed_committed(); let r_set = vec![Arc::new(ev1)]; @@ -2012,6 +2020,16 @@ mod tests { modlist!([m_purge(Attribute::Name)]), ); + // Name Set + let me_set = ModifyEvent::new_impersonate_entry( + E_TEST_ACCOUNT_1.clone(), + filter_all!(f_eq( + Attribute::Name, + PartialValue::new_iname("testperson1") + )), + modlist!([Modify::Set(Attribute::Name, ValueSetIname::new("value"))]), + ); + // Class account pres let me_pres_class = ModifyEvent::new_impersonate_entry( E_TEST_ACCOUNT_1.clone(), @@ -2043,6 +2061,19 @@ mod tests { modlist!([m_purge(Attribute::Class)]), ); + // Set Class + let me_set_class = ModifyEvent::new_impersonate_entry( + E_TEST_ACCOUNT_1.clone(), + filter_all!(f_eq( + Attribute::Name, + PartialValue::new_iname("testperson1") + )), + modlist!([Modify::Set( + Attribute::Class, + EntryClass::Account.to_valueset() + )]), + ); + // Allow name and class, class is account let acp_allow = AccessControlModify::from_raw( "test_modify_allow", @@ -2104,6 +2135,8 @@ mod tests { test_acp_modify!(&me_rem, vec![acp_allow.clone()], &r_set, true); // test allowed purge test_acp_modify!(&me_purge, vec![acp_allow.clone()], &r_set, true); + // test allowed set + test_acp_modify!(&me_set, vec![acp_allow.clone()], &r_set, true); // Test rejected pres test_acp_modify!(&me_pres, vec![acp_deny.clone()], &r_set, false); @@ -2111,22 +2144,31 @@ mod tests { test_acp_modify!(&me_rem, vec![acp_deny.clone()], &r_set, false); // Test rejected purge test_acp_modify!(&me_purge, vec![acp_deny.clone()], &r_set, false); + // Test rejected set + test_acp_modify!(&me_set, vec![acp_deny.clone()], &r_set, false); // test allowed pres class test_acp_modify!(&me_pres_class, vec![acp_allow.clone()], &r_set, true); // test allowed rem class test_acp_modify!(&me_rem_class, vec![acp_allow.clone()], &r_set, true); // test reject purge-class even if class present in allowed remattrs - test_acp_modify!(&me_purge_class, vec![acp_allow], &r_set, false); + test_acp_modify!(&me_purge_class, vec![acp_allow.clone()], &r_set, false); + // test allowed set class + test_acp_modify!(&me_set_class, vec![acp_allow], &r_set, true); // Test reject pres class, but class not in classes test_acp_modify!(&me_pres_class, vec![acp_no_class.clone()], &r_set, false); // Test reject pres class, class in classes but not in pres attrs test_acp_modify!(&me_pres_class, vec![acp_deny.clone()], &r_set, false); // test reject rem class, but class not in classes - test_acp_modify!(&me_rem_class, vec![acp_no_class], &r_set, false); + test_acp_modify!(&me_rem_class, vec![acp_no_class.clone()], &r_set, false); // test reject rem class, class in classes but not in pres attrs - test_acp_modify!(&me_rem_class, vec![acp_deny], &r_set, false); + test_acp_modify!(&me_rem_class, vec![acp_deny.clone()], &r_set, false); + + // Test reject set class, but class not in classes + test_acp_modify!(&me_set_class, vec![acp_no_class], &r_set, false); + // Test reject set class, class in classes but not in pres attrs + test_acp_modify!(&me_set_class, vec![acp_deny], &r_set, false); } #[test] diff --git a/server/lib/src/server/batch_modify.rs b/server/lib/src/server/batch_modify.rs index 37665a4b8..3ab04d066 100644 --- a/server/lib/src/server/batch_modify.rs +++ b/server/lib/src/server/batch_modify.rs @@ -1,9 +1,9 @@ use super::{ChangeFlag, QueryServerWriteTransaction}; use crate::prelude::*; use crate::server::Plugins; -use hashbrown::HashMap; +use std::collections::BTreeMap; -pub type ModSetValid = HashMap>; +pub type ModSetValid = BTreeMap>; pub struct BatchModifyEvent { pub ident: Identity, diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index 1d8b6f792..0c07c6bc5 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -1,19 +1,6 @@ //! `server` contains the query server, which is the main high level construction //! to coordinate queries and operations in the server. -use crate::be::{Backend, BackendReadTransaction, BackendTransaction, BackendWriteTransaction}; -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::server::ScimReference; -use kanidm_proto::scim_v1::ScimEntryGetQuery; -use std::collections::BTreeSet; -use std::str::FromStr; -use std::sync::Arc; -use tokio::sync::{Semaphore, SemaphorePermit}; -use tracing::trace; -// We use so many, we just import them all ... use self::access::{ profiles::{ AccessControlCreate, AccessControlDelete, AccessControlModify, AccessControlSearch, @@ -25,6 +12,7 @@ use self::keys::{ KeyObject, KeyProvider, KeyProviders, KeyProvidersReadTransaction, KeyProvidersTransaction, KeyProvidersWriteTransaction, }; +use crate::be::{Backend, BackendReadTransaction, BackendTransaction, BackendWriteTransaction}; use crate::filter::{ Filter, FilterInvalid, FilterValid, FilterValidResolved, ResolveFilterCache, ResolveFilterCacheReadTxn, @@ -42,6 +30,21 @@ use crate::schema::{ use crate::value::{CredentialType, EXTRACT_VAL_DN}; use crate::valueset::uuid_to_proto_string; use crate::valueset::ScimValueIntermediate; +use crate::valueset::*; +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::server::ScimOAuth2ClaimMap; +use kanidm_proto::scim_v1::server::ScimOAuth2ScopeMap; +use kanidm_proto::scim_v1::server::ScimReference; +use kanidm_proto::scim_v1::JsonValue; +use kanidm_proto::scim_v1::ScimEntryGetQuery; +use std::collections::BTreeSet; +use std::str::FromStr; +use std::sync::Arc; +use tokio::sync::{Semaphore, SemaphorePermit}; +use tracing::trace; pub(crate) mod access; pub mod batch_modify; @@ -52,6 +55,7 @@ pub(crate) mod keys; pub(crate) mod migrations; pub mod modify; pub(crate) mod recycle; +pub mod scim; const RESOLVE_FILTER_CACHE_MAX: usize = 256; const RESOLVE_FILTER_CACHE_LOCAL: usize = 8; @@ -845,29 +849,283 @@ pub trait QueryServerTransaction<'a> { scim_value_intermediate: ScimValueIntermediate, ) -> Result, OperationError> { match scim_value_intermediate { - ScimValueIntermediate::Refer(uuid) => { - if let Some(option) = self.uuid_to_spn(uuid)? { - Ok(Some(ScimValueKanidm::EntryReference(ScimReference { - uuid, - value: option.to_proto_string_clone(), - }))) - } else { - // TODO: didn't have spn, fallback to uuid.to_string ? - Ok(None) - } - } - ScimValueIntermediate::ReferMany(uuids) => { - let mut scim_references = vec![]; - for uuid in uuids { - if let Some(option) = self.uuid_to_spn(uuid)? { - scim_references.push(ScimReference { - uuid, - value: option.to_proto_string_clone(), - }) - } - } + ScimValueIntermediate::References(uuids) => { + let scim_references = uuids + .into_iter() + .map(|uuid| { + self.uuid_to_spn(uuid) + .and_then(|maybe_value| { + maybe_value.ok_or(OperationError::InvalidValueState) + }) + .map(|value| ScimReference { + uuid, + value: value.to_proto_string_clone(), + }) + }) + .collect::, _>>()?; Ok(Some(ScimValueKanidm::EntryReferences(scim_references))) } + ScimValueIntermediate::Oauth2ClaimMap(unresolved_maps) => { + let scim_claim_maps = unresolved_maps + .into_iter() + .map( + |UnresolvedScimValueOauth2ClaimMap { + group_uuid, + claim, + join_char, + values, + }| { + self.uuid_to_spn(group_uuid) + .and_then(|maybe_value| { + maybe_value.ok_or(OperationError::InvalidValueState) + }) + .map(|value| ScimOAuth2ClaimMap { + group: value.to_proto_string_clone(), + group_uuid, + claim, + join_char, + values, + }) + }, + ) + .collect::, _>>()?; + + Ok(Some(ScimValueKanidm::OAuth2ClaimMap(scim_claim_maps))) + } + + ScimValueIntermediate::Oauth2ScopeMap(unresolved_maps) => { + let scim_claim_maps = unresolved_maps + .into_iter() + .map(|UnresolvedScimValueOauth2ScopeMap { group_uuid, scopes }| { + self.uuid_to_spn(group_uuid) + .and_then(|maybe_value| { + maybe_value.ok_or(OperationError::InvalidValueState) + }) + .map(|value| ScimOAuth2ScopeMap { + group: value.to_proto_string_clone(), + group_uuid, + scopes, + }) + }) + .collect::, _>>()?; + + Ok(Some(ScimValueKanidm::OAuth2ScopeMap(scim_claim_maps))) + } + } + } + + fn resolve_scim_json_put( + &mut self, + attr: &Attribute, + value: Option, + ) -> Result, 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); + }; + + 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) + } + } + .map(Some) + } + + fn resolve_valueset_intermediate( + &mut self, + vs_inter: ValueSetIntermediate, + ) -> Result { + match vs_inter { + ValueSetIntermediate::References { + mut resolved, + unresolved, + } => { + for value in unresolved { + let un = self.name_to_uuid(value.as_str()).unwrap_or_else(|_| { + warn!( + ?value, + "Value can not be resolved to a uuid - assuming it does not exist." + ); + UUID_DOES_NOT_EXIST + }); + + resolved.insert(un); + } + + let vs = ValueSetRefer::from_set(resolved); + Ok(vs) + } + + ValueSetIntermediate::Oauth2ClaimMap { + mut resolved, + unresolved, + } => { + resolved.extend(unresolved.into_iter().map( + |UnresolvedValueSetOauth2ClaimMap { + group_name, + claim, + join_char, + claim_values, + }| { + let group_uuid = + self.name_to_uuid(group_name.as_str()).unwrap_or_else(|_| { + warn!( + ?group_name, + "Value can not be resolved to a uuid - assuming it does not exist." + ); + UUID_DOES_NOT_EXIST + }); + + ResolvedValueSetOauth2ClaimMap { + group_uuid, + claim, + join_char, + claim_values, + } + }, + )); + + let vs = ValueSetOauthClaimMap::from_set(resolved); + Ok(vs) + } + + ValueSetIntermediate::Oauth2ScopeMap { + mut resolved, + unresolved, + } => { + resolved.extend(unresolved.into_iter().map( + |UnresolvedValueSetOauth2ScopeMap { group_name, scopes }| { + let group_uuid = + self.name_to_uuid(group_name.as_str()).unwrap_or_else(|_| { + warn!( + ?group_name, + "Value can not be resolved to a uuid - assuming it does not exist." + ); + UUID_DOES_NOT_EXIST + }); + + ResolvedValueSetOauth2ScopeMap { group_uuid, scopes } + }, + )); + + let vs = ValueSetOauthScopeMap::from_set(resolved); + Ok(vs) + } } } diff --git a/server/lib/src/server/scim.rs b/server/lib/src/server/scim.rs new file mode 100644 index 000000000..7422bbc7e --- /dev/null +++ b/server/lib/src/server/scim.rs @@ -0,0 +1,344 @@ +use crate::prelude::*; +use crate::server::batch_modify::{BatchModifyEvent, ModSetValid}; +use kanidm_proto::scim_v1::client::ScimEntryPutGeneric; +use std::collections::BTreeMap; + +#[derive(Debug, Clone)] +pub struct ScimEntryPutEvent { + /// The identity performing the change. + pub ident: Identity, + + // future - etags to detect version changes. + /// The target entry that will be changed + pub target: Uuid, + /// Update an attribute to contain the following value state. + /// If the attribute is None, it is removed. + pub attrs: BTreeMap>, +} + +impl ScimEntryPutEvent { + pub fn try_from( + ident: Identity, + entry: ScimEntryPutGeneric, + qs: &mut QueryServerWriteTransaction, + ) -> Result { + 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::>()?; + + Ok(ScimEntryPutEvent { + ident, + target, + attrs, + }) + } +} + +impl<'a> QueryServerWriteTransaction<'a> { + /// 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 { + let ScimEntryPutEvent { + ident, + target, + attrs, + } = scim_entry_put; + + // This function transforms the put event into a modify event. + let mods_invalid: ModifyList = 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, + }; + + 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) + } + } + } + } +} + +#[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" => {} + _ => assert!(false), + }; + + // 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.get(&Attribute::Description).is_none()); + + // 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(), + })); + } + _ => assert!(false), + }; + + // 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(), + })); + } + _ => assert!(false), + }; + + // 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(), + })); + } + _ => assert!(false), + }; + + // 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.get(&Attribute::Member).is_none()); + } +} diff --git a/server/lib/src/utils.rs b/server/lib/src/utils.rs index c844cd33d..1c762b027 100644 --- a/server/lib/src/utils.rs +++ b/server/lib/src/utils.rs @@ -4,7 +4,6 @@ use crate::prelude::*; use hashbrown::HashSet; use rand::distributions::{Distribution, Uniform}; use rand::{thread_rng, Rng}; -use std::collections::BTreeSet; use std::ops::Range; #[derive(Debug)] @@ -75,20 +74,6 @@ pub fn readable_password_from_random() -> String { ) } -pub fn str_join(set: &BTreeSet) -> String { - let alloc_len = set.iter().fold(0, |acc, s| acc + s.len() + 1); - let mut buf = String::with_capacity(alloc_len); - set.iter().for_each(|s| { - buf.push_str(s); - buf.push(' '); - }); - - // Remove the excess trailing space. - let _ = buf.pop(); - - buf -} - impl Distribution for DistinctAlpha { fn sample(&self, rng: &mut R) -> char { const RANGE: u32 = 55; diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs index a2eedd4f7..3bde2cdd7 100644 --- a/server/lib/src/value.rs +++ b/server/lib/src/value.rs @@ -43,6 +43,7 @@ use crate::valueset::image::ImageValueThings; use crate::valueset::uuid_to_proto_string; use kanidm_proto::internal::{ApiTokenPurpose, Filter as ProtoFilter, UiHint}; +use kanidm_proto::scim_v1::ScimOauth2ClaimMapJoinChar; use kanidm_proto::v1::UatPurposeStatus; use std::hash::Hash; @@ -1113,6 +1114,34 @@ pub enum OauthClaimMapJoin { JsonArray, } +impl From for ScimOauth2ClaimMapJoinChar { + fn from(value: OauthClaimMapJoin) -> Self { + match value { + OauthClaimMapJoin::CommaSeparatedValue => { + ScimOauth2ClaimMapJoinChar::CommaSeparatedValue + } + OauthClaimMapJoin::SpaceSeparatedValue => { + ScimOauth2ClaimMapJoinChar::SpaceSeparatedValue + } + OauthClaimMapJoin::JsonArray => ScimOauth2ClaimMapJoinChar::JsonArray, + } + } +} + +impl From for OauthClaimMapJoin { + fn from(value: ScimOauth2ClaimMapJoinChar) -> Self { + match value { + ScimOauth2ClaimMapJoinChar::CommaSeparatedValue => { + OauthClaimMapJoin::CommaSeparatedValue + } + ScimOauth2ClaimMapJoinChar::SpaceSeparatedValue => { + OauthClaimMapJoin::SpaceSeparatedValue + } + ScimOauth2ClaimMapJoinChar::JsonArray => OauthClaimMapJoin::JsonArray, + } + } +} + impl OauthClaimMapJoin { pub(crate) fn to_str(self) -> &'static str { match self { diff --git a/server/lib/src/valueset/address.rs b/server/lib/src/valueset/address.rs index c183cfa78..f50d1b58c 100644 --- a/server/lib/src/valueset/address.rs +++ b/server/lib/src/valueset/address.rs @@ -1,15 +1,16 @@ -use std::collections::BTreeSet; - -use smolset::SmolSet; - use crate::be::dbvalue::DbValueAddressV1; use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; use crate::value::{Address, VALIDATE_EMAIL_RE}; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - -use kanidm_proto::scim_v1::server::{ScimAddress, ScimMail}; +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::client::ScimAddress as ScimAddressClient; +use kanidm_proto::scim_v1::JsonValue; +use kanidm_proto::scim_v1::{server::ScimAddress, ScimMail}; +use smolset::SmolSet; +use std::collections::BTreeSet; #[derive(Debug, Clone)] pub struct ValueSetAddress { @@ -54,6 +55,43 @@ impl ValueSetAddress { } } +impl ValueSetScimPut for ValueSetAddress { + fn from_scim_json_put(value: JsonValue) -> Result { + let addresses: Vec = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Address syntax invalid"); + OperationError::SC0011AddressSyntaxInvalid + })?; + + let set = addresses + .into_iter() + .map( + |ScimAddressClient { + street_address, + locality, + region, + postal_code, + country, + }| { + let formatted = + format!("{street_address}, {locality}, {region}, {postal_code}, {country}"); + Address { + formatted, + street_address, + locality, + region, + postal_code, + country, + } + }, + ) + .collect(); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetAddress { + set, + }))) + } +} + impl FromIterator
for Option> { fn from_iter(iter: T) -> Option> where @@ -286,6 +324,44 @@ impl ValueSetEmailAddress { } } +impl ValueSetScimPut for ValueSetEmailAddress { + fn from_scim_json_put(value: JsonValue) -> Result { + let scim_mails: Vec = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Mail Attribute Syntax Invalid"); + OperationError::SC0003MailSyntaxInvalid + })?; + + let mut primary = None; + let set: BTreeSet<_> = scim_mails + .into_iter() + .map( + |ScimMail { + value, + primary: is_primary, + }| { + if is_primary { + primary = Some(value.clone()); + } + value + }, + ) + .collect(); + + let primary = primary + .or_else(|| set.iter().next().cloned()) + .ok_or_else(|| { + error!( + "Mail attribute has no values that can be used as the primary mail address." + ); + OperationError::SC0003MailSyntaxInvalid + })?; + + Ok(ValueSetResolveStatus::Resolved(Box::new( + ValueSetEmailAddress { primary, set }, + ))) + } +} + impl ValueSetT for ValueSetEmailAddress { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -607,7 +683,10 @@ mod tests { "value": "claire@example.com" } ]"#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive(vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } #[test] @@ -632,6 +711,9 @@ mod tests { } ]"#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive(vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/binary.rs b/server/lib/src/valueset/binary.rs index 1aa4daa40..3ceff9467 100644 --- a/server/lib/src/valueset/binary.rs +++ b/server/lib/src/valueset/binary.rs @@ -1,15 +1,13 @@ -use crate::valueset::ScimResolveStatus; -use base64urlsafedata::Base64UrlSafeData; -use std::collections::btree_map::Entry as BTreeEntry; -use std::collections::BTreeMap; - -use smolset::SmolSet; - use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; +use crate::valueset::ScimResolveStatus; use crate::valueset::{DbValueSetV2, ValueSet}; +use base64urlsafedata::Base64UrlSafeData; use kanidm_proto::scim_v1::server::ScimBinary; +use smolset::SmolSet; +use std::collections::btree_map::Entry as BTreeEntry; +use std::collections::BTreeMap; #[derive(Debug, Clone)] pub struct ValueSetPrivateBinary { diff --git a/server/lib/src/valueset/bool.rs b/server/lib/src/valueset/bool.rs index e39c73940..6f8f05fac 100644 --- a/server/lib/src/valueset/bool.rs +++ b/server/lib/src/valueset/bool.rs @@ -1,7 +1,8 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; +use crate::valueset::{DbValueSetV2, ValueSet, ValueSetResolveStatus, ValueSetScimPut}; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; #[derive(Debug, Clone)] @@ -37,6 +38,22 @@ impl ValueSetBool { } } +impl ValueSetScimPut for ValueSetBool { + fn from_scim_json_put(value: JsonValue) -> Result { + let value: bool = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM boolean syntax invalid"); + OperationError::SC0005BoolSyntaxInvalid + })?; + + let mut set = SmolSet::new(); + set.insert(value); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetBool { + set, + }))) + } +} + impl ValueSetT for ValueSetBool { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -168,6 +185,9 @@ mod tests { #[test] fn test_scim_boolean() { let vs: ValueSet = ValueSetBool::new(true); - crate::valueset::scim_json_reflexive(vs, "true"); + crate::valueset::scim_json_reflexive(vs.clone(), "true"); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/certificate.rs b/server/lib/src/valueset/certificate.rs index e246cb62d..596a349d7 100644 --- a/server/lib/src/valueset/certificate.rs +++ b/server/lib/src/valueset/certificate.rs @@ -2,8 +2,10 @@ use crate::be::dbvalue::DbValueCertificate; use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; +use crate::valueset::{DbValueSetV2, ValueSet, ValueSetResolveStatus, ValueSetScimPut}; +use kanidm_proto::scim_v1::client::ScimCertificate as ClientScimCertificate; use kanidm_proto::scim_v1::server::ScimCertificate; +use kanidm_proto::scim_v1::JsonValue; use std::collections::BTreeMap; use kanidm_lib_crypto::{ @@ -101,6 +103,41 @@ impl ValueSetCertificate { } } +impl ValueSetScimPut for ValueSetCertificate { + fn from_scim_json_put(value: JsonValue) -> Result { + let der_values: Vec = + serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Certificate syntax invalid"); + OperationError::SC0012CertificateSyntaxInvalid + })?; + + // For each one, check it's a real der certificate. + let mut map = BTreeMap::new(); + + for ClientScimCertificate { der } in der_values { + // Parse the DER + let certificate = Certificate::from_der(&der) + .map(Box::new) + .map_err(|x509_err| { + error!(?x509_err, "Unable to restore certificate from DER"); + OperationError::SC0013CertificateInvalidDer + })?; + + // sha256 the public key + let pk_s256 = x509_public_key_s256(&certificate).ok_or_else(|| { + error!("Unable to digest public key"); + OperationError::SC0014CertificateInvalidDigest + })?; + + map.insert(pk_s256, certificate); + } + + Ok(ValueSetResolveStatus::Resolved(Box::new( + ValueSetCertificate { map }, + ))) + } +} + impl ValueSetT for ValueSetCertificate { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -313,5 +350,8 @@ raBy6edj7W0EIH+yQxkDEwIhAI0nVKaI6duHLAvtKW6CfEQFG6jKg7dyk37YYiRD .unwrap(); assert_eq!(cert.s256, expect_s256); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/cred.rs b/server/lib/src/valueset/cred.rs index 6947ce97e..09205fb16 100644 --- a/server/lib/src/valueset/cred.rs +++ b/server/lib/src/valueset/cred.rs @@ -1,13 +1,3 @@ -use crate::valueset::ScimResolveStatus; -use smolset::SmolSet; -use std::collections::btree_map::Entry as BTreeEntry; -use std::collections::BTreeMap; -use time::OffsetDateTime; - -use webauthn_rs::prelude::{ - AttestationCaList, AttestedPasskey as AttestedPasskeyV4, Passkey as PasskeyV4, -}; - use crate::be::dbvalue::{ DbValueAttestedPasskeyV1, DbValueCredV1, DbValueIntentTokenStateV1, DbValuePasskeyV1, }; @@ -16,9 +6,18 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; use crate::value::{CredUpdateSessionPerms, CredentialType, IntentTokenState}; -use crate::valueset::{DbValueSetV2, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; use kanidm_proto::scim_v1::server::{ScimIntentToken, ScimIntentTokenState}; +use kanidm_proto::scim_v1::JsonValue; +use smolset::SmolSet; +use std::collections::btree_map::Entry as BTreeEntry; +use std::collections::BTreeMap; +use time::OffsetDateTime; +use webauthn_rs::prelude::{ + AttestationCaList, AttestedPasskey as AttestedPasskeyV4, Passkey as PasskeyV4, +}; #[derive(Debug, Clone)] pub struct ValueSetCredential { @@ -880,6 +879,29 @@ impl ValueSetCredentialType { } } +impl ValueSetScimPut for ValueSetCredentialType { + fn from_scim_json_put(value: JsonValue) -> Result { + let value = serde_json::from_value::(value) + .map_err(|err| { + error!(?err, "SCIM CredentialType syntax invalid"); + OperationError::SC0015CredentialTypeSyntaxInvalid + }) + .and_then(|value| { + CredentialType::try_from(value.as_str()).map_err(|()| { + error!("SCIM CredentialType syntax invalid - value"); + OperationError::SC0015CredentialTypeSyntaxInvalid + }) + })?; + + let mut set = SmolSet::new(); + set.insert(value); + + Ok(ValueSetResolveStatus::Resolved(Box::new( + ValueSetCredentialType { set }, + ))) + } +} + impl ValueSetT for ValueSetCredentialType { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -949,9 +971,10 @@ impl ValueSetT for ValueSetCredentialType { } fn to_scim_value(&self) -> Option { - Some(ScimResolveStatus::Resolved(ScimValueKanidm::from( - self.set.iter().map(|ct| ct.to_string()).collect::>(), - ))) + self.set + .iter() + .next() + .map(|ct| ScimResolveStatus::Resolved(ScimValueKanidm::from(ct.to_string()))) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -1166,6 +1189,9 @@ mod tests { #[test] fn test_scim_credential_type() { let vs: ValueSet = ValueSetCredentialType::new(CredentialType::Mfa); - crate::valueset::scim_json_reflexive(vs, r#"["mfa"]"#); + crate::valueset::scim_json_reflexive(vs.clone(), r#""mfa""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/datetime.rs b/server/lib/src/valueset/datetime.rs index bfb4a6fe8..b5e5c9d0e 100644 --- a/server/lib/src/valueset/datetime.rs +++ b/server/lib/src/valueset/datetime.rs @@ -1,8 +1,9 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; -use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::{client::ScimDateTime, JsonValue}; use smolset::SmolSet; use time::OffsetDateTime; @@ -46,6 +47,22 @@ impl ValueSetDateTime { } } +impl ValueSetScimPut for ValueSetDateTime { + fn from_scim_json_put(value: JsonValue) -> Result { + let ScimDateTime { date_time } = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM DateTime syntax invalid"); + OperationError::SC0010DateTimeSyntaxInvalid + })?; + + let mut set = SmolSet::new(); + set.insert(date_time); + + Ok(ValueSetResolveStatus::Resolved(Box::new( + ValueSetDateTime { set }, + ))) + } +} + impl ValueSetT for ValueSetDateTime { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -125,14 +142,7 @@ impl ValueSetT for ValueSetDateTime { } fn to_scim_value(&self) -> Option { - let mut iter = self.set.iter().copied(); - if self.len() == 1 { - let v = iter.next().unwrap_or(OffsetDateTime::UNIX_EPOCH); - Some(v.into()) - } else { - let arr = iter.collect::>(); - Some(arr.into()) - } + self.set.iter().next().copied().map(|v| v.into()) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -200,6 +210,9 @@ mod tests { let odt = OffsetDateTime::UNIX_EPOCH + Duration::from_secs(69_420); let vs: ValueSet = ValueSetDateTime::new(odt); - crate::valueset::scim_json_reflexive(vs, r#""1970-01-01T19:17:00Z""#); + crate::valueset::scim_json_reflexive(vs.clone(), r#""1970-01-01T19:17:00Z""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/eckey.rs b/server/lib/src/valueset/eckey.rs index 9c66062a7..8cb502764 100644 --- a/server/lib/src/valueset/eckey.rs +++ b/server/lib/src/valueset/eckey.rs @@ -1,13 +1,11 @@ -use crate::valueset::ScimResolveStatus; -use std::iter::{self}; - use super::ValueSet; use crate::be::dbvalue::DbValueSetV2; use crate::prelude::*; use crate::value::{PartialValue, SyntaxType, Value}; - +use crate::valueset::ScimResolveStatus; use openssl::ec::EcKey; use openssl::pkey::{Private, Public}; +use std::iter::{self}; #[derive(Debug, Clone)] struct EcKeyPrivate { diff --git a/server/lib/src/valueset/hexstring.rs b/server/lib/src/valueset/hexstring.rs index 5ee930a60..f1ea14224 100644 --- a/server/lib/src/valueset/hexstring.rs +++ b/server/lib/src/valueset/hexstring.rs @@ -183,6 +183,9 @@ mod tests { fn test_scim_hexstring() { let vs: ValueSet = ValueSetHexString::new("D68475C760A7A0F6A924C28F095573A967F600D6".to_string()); - crate::valueset::scim_json_reflexive(vs, r#""D68475C760A7A0F6A924C28F095573A967F600D6""#); + crate::valueset::scim_json_reflexive( + vs.clone(), + r#""D68475C760A7A0F6A924C28F095573A967F600D6""#, + ); } } diff --git a/server/lib/src/valueset/image/mod.rs b/server/lib/src/valueset/image/mod.rs index 819d63683..634d8ae55 100644 --- a/server/lib/src/valueset/image/mod.rs +++ b/server/lib/src/valueset/image/mod.rs @@ -458,6 +458,7 @@ impl ValueSetT for ValueSetImage { #[cfg(test)] mod tests { + // use super::ValueSetImage; use super::{ImageType, ImageValue, ImageValueThings}; #[test] @@ -511,8 +512,8 @@ mod tests { assert!(!image.hash_imagevalue().is_empty()); } - /* // This test is broken on github as it appears to be changing the binary image hash. + /* #[test] fn test_scim_imagevalue() { let filename = format!( @@ -531,7 +532,7 @@ mod tests { "142dc7984dd548dd5dacfe2ad30f8473e3217e39b3b6c8d17a0cf6e4e24b02e0" ]"#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive(vs.clone(), data); } */ } diff --git a/server/lib/src/valueset/iname.rs b/server/lib/src/valueset/iname.rs index c723dd1ae..52b6d9a44 100644 --- a/server/lib/src/valueset/iname.rs +++ b/server/lib/src/valueset/iname.rs @@ -2,7 +2,8 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; +use crate::valueset::{DbValueSetV2, ValueSet, ValueSetResolveStatus, ValueSetScimPut}; +use kanidm_proto::scim_v1::JsonValue; use std::collections::BTreeSet; @@ -39,6 +40,22 @@ impl ValueSetIname { } } +impl ValueSetScimPut for ValueSetIname { + fn from_scim_json_put(value: JsonValue) -> Result { + let value = serde_json::from_value::(value).map_err(|err| { + error!(?err, "SCIM Iname Syntax Invalid"); + OperationError::SC0016InameSyntaxInvalid + })?; + + let mut set = BTreeSet::new(); + set.insert(value.to_lowercase()); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetIname { + set, + }))) + } +} + impl ValueSetT for ValueSetIname { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -209,6 +226,9 @@ mod tests { #[test] fn test_scim_iname() { let vs: ValueSet = ValueSetIname::new("stevo"); - crate::valueset::scim_json_reflexive(vs, r#""stevo""#); + crate::valueset::scim_json_reflexive(vs.clone(), r#""stevo""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/index.rs b/server/lib/src/valueset/index.rs index c5be256f9..fc367a4a4 100644 --- a/server/lib/src/valueset/index.rs +++ b/server/lib/src/valueset/index.rs @@ -1,7 +1,8 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; +use crate::valueset::{DbValueSetV2, ValueSet, ValueSetResolveStatus, ValueSetScimPut}; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; @@ -38,6 +39,29 @@ impl ValueSetIndex { } } +impl ValueSetScimPut for ValueSetIndex { + fn from_scim_json_put(value: JsonValue) -> Result { + let value = serde_json::from_value::>(value).map_err(|err| { + error!(?err, "SCIM IndexType syntax invalid"); + OperationError::SC0009IndexTypeSyntaxInvalid + })?; + + let set = value + .into_iter() + .map(|s| { + IndexType::try_from(s.as_str()).map_err(|_| { + error!("SCIM IndexType syntax invalid value"); + OperationError::SC0009IndexTypeSyntaxInvalid + }) + }) + .collect::>()?; + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetIndex { + set, + }))) + } +} + impl ValueSetT for ValueSetIndex { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -159,6 +183,9 @@ mod tests { #[test] fn test_scim_index() { let vs: ValueSet = ValueSetIndex::new(IndexType::Equality); - crate::valueset::scim_json_reflexive(vs, r#"["EQUALITY"]"#); + crate::valueset::scim_json_reflexive(vs.clone(), r#"["EQUALITY"]"#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/iutf8.rs b/server/lib/src/valueset/iutf8.rs index fa69a1dc3..6bc82490e 100644 --- a/server/lib/src/valueset/iutf8.rs +++ b/server/lib/src/valueset/iutf8.rs @@ -3,8 +3,9 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; - +use crate::valueset::{DbValueSetV2, ValueSet, ValueSetResolveStatus, ValueSetScimPut}; +use kanidm_proto::scim_v1::client::ScimStrings; +use kanidm_proto::scim_v1::JsonValue; use std::collections::BTreeSet; #[derive(Debug, Clone)] @@ -40,6 +41,21 @@ impl ValueSetIutf8 { } } +impl ValueSetScimPut for ValueSetIutf8 { + fn from_scim_json_put(value: JsonValue) -> Result { + let ScimStrings(values) = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Iutf8 Syntax Invalid"); + OperationError::SC0017Iutf8SyntaxInvalid + })?; + + let set = values.iter().map(|s| s.to_lowercase()).collect(); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetIutf8 { + set, + }))) + } +} + impl ValueSetT for ValueSetIutf8 { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -128,9 +144,11 @@ impl ValueSetT for ValueSetIutf8 { } fn validate(&self, _schema_attr: &SchemaAttribute) -> bool { - self.set - .iter() - .all(|s| Value::validate_str_escapes(s) && Value::validate_singleline(s)) + self.set.iter().all(|s| { + Value::validate_str_escapes(s) && Value::validate_singleline(s) && + // I'm sure there is a better way ... + s.to_lowercase().as_str() == s.as_str() + }) } fn to_proto_string_clone_iter(&self) -> Box + '_> { @@ -209,6 +227,9 @@ mod tests { #[test] fn test_scim_iutf8() { let vs: ValueSet = ValueSetIutf8::new("lowercase string"); - crate::valueset::scim_json_reflexive(vs, r#""lowercase string""#); + crate::valueset::scim_json_reflexive(vs.clone(), r#""lowercase string""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/json.rs b/server/lib/src/valueset/json.rs index e6d87b154..fc069cd54 100644 --- a/server/lib/src/valueset/json.rs +++ b/server/lib/src/valueset/json.rs @@ -3,7 +3,6 @@ use crate::schema::SchemaAttribute; use crate::valueset::ScimResolveStatus; use crate::valueset::{DbValueSetV2, ValueSet}; use kanidm_proto::internal::Filter as ProtoFilter; - use smolset::SmolSet; #[derive(Debug, Clone)] @@ -206,6 +205,9 @@ mod tests { "{\"pres\":\"class\"}" ] "#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive(vs.clone(), data); + + // Test that we can parse json values into a valueset. + // crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/key_internal.rs b/server/lib/src/valueset/key_internal.rs index f1d2f324a..7e08c0cc1 100644 --- a/server/lib/src/valueset/key_internal.rs +++ b/server/lib/src/valueset/key_internal.rs @@ -1,18 +1,14 @@ +use crate::be::dbvalue::{DbValueKeyInternal, DbValueKeyStatus, DbValueKeyUsage}; use crate::prelude::*; -use crate::valueset::ScimResolveStatus; - use crate::server::keys::KeyId; use crate::value::{KeyStatus, KeyUsage}; - -use crate::be::dbvalue::{DbValueKeyInternal, DbValueKeyStatus, DbValueKeyUsage}; +use crate::valueset::ScimResolveStatus; use crate::valueset::{DbValueSetV2, ValueSet}; - +use kanidm_proto::scim_v1::server::ScimKeyInternal; use std::collections::BTreeMap; use std::fmt; use time::OffsetDateTime; -use kanidm_proto::scim_v1::server::ScimKeyInternal; - #[derive(Clone, PartialEq, Eq)] pub struct KeyInternalData { pub usage: KeyUsage, diff --git a/server/lib/src/valueset/mod.rs b/server/lib/src/valueset/mod.rs index fff7ee9ba..0cb86a506 100644 --- a/server/lib/src/valueset/mod.rs +++ b/server/lib/src/valueset/mod.rs @@ -1,30 +1,30 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use compact_jwt::{crypto::JwsRs256Signer, JwsEs256Signer}; -use dyn_clone::DynClone; -use hashbrown::HashSet; -use kanidm_lib_crypto::{x509_cert::Certificate, Sha256Digest}; -use kanidm_proto::internal::ImageValue; -use openssl::ec::EcKey; -use openssl::pkey::Private; -use openssl::pkey::Public; -use serde::Serialize; -use serde_with::serde_as; -use smolset::SmolSet; -use sshkey_attest::proto::PublicKey as SshPublicKey; -use time::OffsetDateTime; -use webauthn_rs::prelude::AttestationCaList; -use webauthn_rs::prelude::AttestedPasskey as AttestedPasskeyV4; -use webauthn_rs::prelude::Passkey as PasskeyV4; - use crate::be::dbvalue::DbValueSetV2; use crate::credential::{apppwd::ApplicationPassword, totp::Totp, Credential}; use crate::prelude::*; use crate::repl::cid::Cid; use crate::schema::SchemaAttribute; use crate::server::keys::KeyId; -use crate::value::{Address, ApiToken, CredentialType, IntentTokenState, Oauth2Session, Session}; +use crate::value::{ + Address, ApiToken, CredentialType, IntentTokenState, Oauth2Session, OauthClaimMapJoin, Session, +}; +use compact_jwt::{crypto::JwsRs256Signer, JwsEs256Signer}; +use dyn_clone::DynClone; +use hashbrown::HashSet; +use kanidm_lib_crypto::{x509_cert::Certificate, Sha256Digest}; +use kanidm_proto::internal::ImageValue; use kanidm_proto::internal::{Filter as ProtoFilter, UiHint}; +use kanidm_proto::scim_v1::JsonValue; +use kanidm_proto::scim_v1::ScimOauth2ClaimMapJoinChar; +use openssl::ec::EcKey; +use openssl::pkey::Private; +use openssl::pkey::Public; +use smolset::SmolSet; +use sshkey_attest::proto::PublicKey as SshPublicKey; +use std::collections::{BTreeMap, BTreeSet}; +use time::OffsetDateTime; +use webauthn_rs::prelude::AttestationCaList; +use webauthn_rs::prelude::AttestedPasskey as AttestedPasskeyV4; +use webauthn_rs::prelude::Passkey as PasskeyV4; pub use self::address::{ValueSetAddress, ValueSetEmailAddress}; use self::apppwd::ValueSetApplicationPassword; @@ -661,17 +661,32 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { } } +pub trait ValueSetScimPut { + fn from_scim_json_put(value: JsonValue) -> Result; +} + impl PartialEq for ValueSet { fn eq(&self, other: &ValueSet) -> bool { self.equal(other) } } -#[serde_as] -#[derive(Serialize, Debug, Clone, PartialEq, Eq)] +pub struct UnresolvedScimValueOauth2ClaimMap { + pub group_uuid: Uuid, + pub claim: String, + pub join_char: ScimOauth2ClaimMapJoinChar, + pub values: BTreeSet, +} + +pub struct UnresolvedScimValueOauth2ScopeMap { + pub group_uuid: Uuid, + pub scopes: BTreeSet, +} + pub enum ScimValueIntermediate { - Refer(Uuid), - ReferMany(Vec), + References(Vec), + Oauth2ClaimMap(Vec), + Oauth2ScopeMap(Vec), } pub enum ScimResolveStatus { @@ -707,6 +722,69 @@ impl ScimResolveStatus { } } +pub enum ValueSetResolveStatus { + Resolved(ValueSet), + NeedsResolution(ValueSetIntermediate), +} + +#[cfg(test)] +impl ValueSetResolveStatus { + pub fn assume_resolved(self) -> ValueSet { + match self { + ValueSetResolveStatus::Resolved(v) => v, + ValueSetResolveStatus::NeedsResolution(_) => { + panic!("assume_resolved called on NeedsResolution") + } + } + } + + pub fn assume_unresolved(self) -> ValueSetIntermediate { + match self { + ValueSetResolveStatus::Resolved(_) => panic!("assume_unresolved called on Resolved"), + ValueSetResolveStatus::NeedsResolution(svi) => svi, + } + } +} + +pub enum ValueSetIntermediate { + References { + resolved: BTreeSet, + unresolved: Vec, + }, + Oauth2ClaimMap { + resolved: Vec, + unresolved: Vec, + }, + Oauth2ScopeMap { + resolved: Vec, + unresolved: Vec, + }, +} + +pub struct UnresolvedValueSetOauth2ClaimMap { + pub group_name: String, + pub claim: String, + pub join_char: OauthClaimMapJoin, + pub claim_values: BTreeSet, +} + +pub struct ResolvedValueSetOauth2ClaimMap { + pub group_uuid: Uuid, + pub claim: String, + pub join_char: OauthClaimMapJoin, + pub claim_values: BTreeSet, +} + +pub struct UnresolvedValueSetOauth2ScopeMap { + pub group_name: String, + pub scopes: BTreeSet, +} + +pub struct ResolvedValueSetOauth2ScopeMap { + pub group_uuid: Uuid, + pub scopes: BTreeSet, +} + pub fn uuid_to_proto_string(u: Uuid) -> String { u.as_hyphenated().to_string() } @@ -925,14 +1003,20 @@ pub(crate) fn scim_json_reflexive(vs: ValueSet, data: &str) { let json_value: serde_json::Value = serde_json::to_value(&scim_value).unwrap(); + eprintln!("{}", data); let expect: serde_json::Value = serde_json::from_str(data).unwrap(); assert_eq!(json_value, expect); } #[cfg(test)] -pub(crate) fn scim_json_reflexive_unresolved(vs: ValueSet, data: &str) { - let scim_value = vs.to_scim_value().unwrap().assume_unresolved(); +pub(crate) fn scim_json_reflexive_unresolved( + write_txn: &mut QueryServerWriteTransaction, + vs: ValueSet, + data: &str, +) { + let scim_int_value = vs.to_scim_value().unwrap().assume_unresolved(); + let scim_value = write_txn.resolve_scim_interim(scim_int_value).unwrap(); let strout = serde_json::to_string_pretty(&scim_value).unwrap(); eprintln!("{}", strout); @@ -943,3 +1027,50 @@ pub(crate) fn scim_json_reflexive_unresolved(vs: ValueSet, data: &str) { assert_eq!(json_value, expect); } + +#[cfg(test)] +pub(crate) fn scim_json_put_reflexive( + expect_vs: ValueSet, + additional_tests: &[(JsonValue, ValueSet)], +) { + let scim_value = expect_vs.to_scim_value().unwrap().assume_resolved(); + + let strout = serde_json::to_string_pretty(&scim_value).unwrap(); + eprintln!("{}", strout); + + let generic = serde_json::to_value(scim_value).unwrap(); + // Check that we can turn back into a vs from the generic version. + let vs = T::from_scim_json_put(generic).unwrap().assume_resolved(); + assert_eq!(&vs, &expect_vs); + + // For each additional check, assert they work as expected. + for (jv, expect_vs) in additional_tests { + let vs = T::from_scim_json_put(jv.clone()).unwrap().assume_resolved(); + assert_eq!(&vs, expect_vs); + } +} + +#[cfg(test)] +pub(crate) fn scim_json_put_reflexive_unresolved( + write_txn: &mut QueryServerWriteTransaction, + expect_vs: ValueSet, + additional_tests: &[(JsonValue, ValueSet)], +) { + let scim_int_value = expect_vs.to_scim_value().unwrap().assume_unresolved(); + let scim_value = write_txn.resolve_scim_interim(scim_int_value).unwrap(); + + let generic = serde_json::to_value(scim_value).unwrap(); + // Check that we can turn back into a vs from the generic version. + let vs_inter = T::from_scim_json_put(generic).unwrap().assume_unresolved(); + let vs = write_txn.resolve_valueset_intermediate(vs_inter).unwrap(); + assert_eq!(&vs, &expect_vs); + + // For each additional check, assert they work as expected. + for (jv, expect_vs) in additional_tests { + let vs_inter = T::from_scim_json_put(jv.clone()) + .unwrap() + .assume_unresolved(); + let vs = write_txn.resolve_valueset_intermediate(vs_inter).unwrap(); + assert_eq!(&vs, expect_vs); + } +} diff --git a/server/lib/src/valueset/nsuniqueid.rs b/server/lib/src/valueset/nsuniqueid.rs index a464e9086..ab0746266 100644 --- a/server/lib/src/valueset/nsuniqueid.rs +++ b/server/lib/src/valueset/nsuniqueid.rs @@ -2,7 +2,8 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::value::NSUNIQUEID_RE; use crate::valueset::ScimResolveStatus; -use crate::valueset::{DbValueSetV2, ValueSet}; +use crate::valueset::{DbValueSetV2, ValueSet, ValueSetResolveStatus, ValueSetScimPut}; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; @@ -39,6 +40,22 @@ impl ValueSetNsUniqueId { } } +impl ValueSetScimPut for ValueSetNsUniqueId { + fn from_scim_json_put(value: JsonValue) -> Result { + let value = serde_json::from_value::(value).map_err(|err| { + error!(?err, "SCIM NsUniqueId Syntax Invalid"); + OperationError::SC0018NsUniqueIdSyntaxInvalid + })?; + + let mut set = SmolSet::new(); + set.insert(value.to_lowercase()); + + Ok(ValueSetResolveStatus::Resolved(Box::new( + ValueSetNsUniqueId { set }, + ))) + } +} + impl ValueSetT for ValueSetNsUniqueId { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -172,6 +189,12 @@ mod tests { fn test_scim_nsuniqueid() { let vs: ValueSet = ValueSetNsUniqueId::new("3a163ca0-47624620-a18806b7-50c84c86".to_string()); - crate::valueset::scim_json_reflexive(vs, r#""3a163ca0-47624620-a18806b7-50c84c86""#); + crate::valueset::scim_json_reflexive( + vs.clone(), + r#""3a163ca0-47624620-a18806b7-50c84c86""#, + ); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/oauth.rs b/server/lib/src/valueset/oauth.rs index 1f7da6752..7b1ed8d75 100644 --- a/server/lib/src/valueset/oauth.rs +++ b/server/lib/src/valueset/oauth.rs @@ -5,12 +5,17 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::be::dbvalue::{DbValueOauthClaimMap, DbValueOauthScopeMapV1}; use crate::prelude::*; use crate::schema::SchemaAttribute; -use crate::utils::str_join; use crate::value::{OauthClaimMapJoin, OAUTHSCOPE_RE}; -use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet}; - -use kanidm_proto::scim_v1::server::ScimOAuth2ClaimMap; -use kanidm_proto::scim_v1::server::ScimOAuth2ScopeMap; +use crate::valueset::{ + uuid_to_proto_string, DbValueSetV2, ResolvedValueSetOauth2ClaimMap, + ResolvedValueSetOauth2ScopeMap, ScimValueIntermediate, UnresolvedScimValueOauth2ClaimMap, + UnresolvedScimValueOauth2ScopeMap, UnresolvedValueSetOauth2ClaimMap, + UnresolvedValueSetOauth2ScopeMap, ValueSet, ValueSetIntermediate, ValueSetResolveStatus, + ValueSetScimPut, +}; +use kanidm_proto::scim_v1::client::ScimOAuth2ClaimMap as ClientScimOAuth2ClaimMap; +use kanidm_proto::scim_v1::client::ScimOAuth2ScopeMap as ClientScimOAuth2ScopeMap; +use kanidm_proto::scim_v1::JsonValue; #[derive(Debug, Clone)] pub struct ValueSetOauthScope { @@ -45,6 +50,19 @@ impl ValueSetOauthScope { } } +impl ValueSetScimPut for ValueSetOauthScope { + fn from_scim_json_put(value: JsonValue) -> Result { + let set: BTreeSet = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Oauth2Scope syntax invalid"); + OperationError::SC0019Oauth2ScopeSyntaxInvalid + })?; + + Ok(ValueSetResolveStatus::Resolved(Box::new( + ValueSetOauthScope { set }, + ))) + } +} + impl ValueSetT for ValueSetOauthScope { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -114,7 +132,9 @@ impl ValueSetT for ValueSetOauthScope { } fn to_scim_value(&self) -> Option { - Some(ScimResolveStatus::Resolved(str_join(&self.set).into())) + Some(ScimResolveStatus::Resolved(ScimValueKanidm::ArrayString( + self.set.iter().cloned().collect(), + ))) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -200,6 +220,58 @@ impl ValueSetOauthScopeMap { let map = iter.into_iter().collect(); Some(Box::new(ValueSetOauthScopeMap { map })) } + + pub(crate) fn from_set(resolved: Vec) -> ValueSet { + let map = resolved + .into_iter() + .map(|ResolvedValueSetOauth2ScopeMap { group_uuid, scopes }| (group_uuid, scopes)) + .collect(); + + Box::new(ValueSetOauthScopeMap { map }) + } +} + +impl ValueSetScimPut for ValueSetOauthScopeMap { + fn from_scim_json_put(value: JsonValue) -> Result { + let scope_maps: Vec = + serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Oauth2ScopeMap syntax invalid"); + OperationError::SC0020Oauth2ScopeMapSyntaxInvalid + })?; + + // We make these both the same len as claim maps as during the resolve + // process we move everything from unresolved to resolved, and worst + // case is everything is unresolved. + let mut resolved = Vec::with_capacity(scope_maps.len()); + let mut unresolved = Vec::with_capacity(scope_maps.len()); + + for ClientScimOAuth2ScopeMap { + group, + group_uuid, + scopes, + } in scope_maps.into_iter() + { + match (group_uuid, group) { + (None, None) => { + error!("SCIM Oauth2ScopeMap a group name or uuid must be present"); + return Err(OperationError::SC0021Oauth2ScopeMapMissingGroupIdentifier); + } + (Some(group_uuid), _) => { + resolved.push(ResolvedValueSetOauth2ScopeMap { group_uuid, scopes }) + } + (None, Some(group_name)) => { + unresolved.push(UnresolvedValueSetOauth2ScopeMap { group_name, scopes }) + } + } + } + + Ok(ValueSetResolveStatus::NeedsResolution( + ValueSetIntermediate::Oauth2ScopeMap { + resolved, + unresolved, + }, + )) + } } impl ValueSetT for ValueSetOauthScopeMap { @@ -291,18 +363,18 @@ impl ValueSetT for ValueSetOauthScopeMap { } fn to_scim_value(&self) -> Option { - Some(ScimResolveStatus::Resolved(ScimValueKanidm::from( - self.map - .iter() - .map(|(uuid, scopes)| { - ScimOAuth2ScopeMap { - uuid: *uuid, - // Flattened to a space separated list. - scopes: scopes.clone(), - } - }) - .collect::>(), - ))) + let unresolved_maps = self + .map + .iter() + .map(|(group_uuid, scopes)| UnresolvedScimValueOauth2ScopeMap { + group_uuid: *group_uuid, + scopes: scopes.clone(), + }) + .collect::>(); + + Some(ScimResolveStatus::NeedsResolution( + ScimValueIntermediate::Oauth2ScopeMap(unresolved_maps), + )) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -420,6 +492,45 @@ impl ValueSetOauthClaimMap { Ok(Box::new(ValueSetOauthClaimMap { map })) } + pub(crate) fn from_set(resolved: Vec) -> ValueSet { + let mut map = BTreeMap::new(); + + for ResolvedValueSetOauth2ClaimMap { + group_uuid, + claim, + join_char, + claim_values, + } in resolved.into_iter() + { + match map.entry(claim) { + BTreeEntry::Vacant(e) => { + let mut values = BTreeMap::default(); + values.insert(group_uuid, claim_values); + + let claim_map = OauthClaimMapping { + join: join_char, + values, + }; + e.insert(claim_map); + } + BTreeEntry::Occupied(mut e) => { + // Just add the uuid/value, this claim name already exists. + let mapping_mut = e.get_mut(); + match mapping_mut.values.entry(group_uuid) { + BTreeEntry::Vacant(e) => { + e.insert(claim_values); + } + BTreeEntry::Occupied(mut e) => { + e.insert(claim_values); + } + } + } + } + } + + Box::new(ValueSetOauthClaimMap { map }) + } + fn trim(&mut self) { self.map .values_mut() @@ -429,6 +540,59 @@ impl ValueSetOauthClaimMap { } } +impl ValueSetScimPut for ValueSetOauthClaimMap { + fn from_scim_json_put(value: JsonValue) -> Result { + let claim_maps: Vec = + serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Oauth2ClaimMap syntax invalid"); + OperationError::SC0022Oauth2ClaimMapSyntaxInvalid + })?; + + // We make these both the same len as claim maps as during the resolve + // process we move everything from unresolved to resolved, and worst + // case is everything is unresolved. + let mut resolved = Vec::with_capacity(claim_maps.len()); + let mut unresolved = Vec::with_capacity(claim_maps.len()); + + for ClientScimOAuth2ClaimMap { + group, + group_uuid, + claim, + join_char, + values: claim_values, + } in claim_maps.into_iter() + { + let join_char = OauthClaimMapJoin::from(join_char); + + match (group_uuid, group) { + (None, None) => { + error!("SCIM Oauth2ClaimMap a group name or uuid must be present"); + return Err(OperationError::SC0023Oauth2ClaimMapMissingGroupIdentifier); + } + (Some(group_uuid), _) => resolved.push(ResolvedValueSetOauth2ClaimMap { + group_uuid, + claim, + join_char, + claim_values, + }), + (None, Some(group_name)) => unresolved.push(UnresolvedValueSetOauth2ClaimMap { + group_name, + claim, + join_char, + claim_values, + }), + } + } + + Ok(ValueSetResolveStatus::NeedsResolution( + ValueSetIntermediate::Oauth2ClaimMap { + resolved, + unresolved, + }, + )) + } +} + impl ValueSetT for ValueSetOauthClaimMap { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -622,22 +786,24 @@ impl ValueSetT for ValueSetOauthClaimMap { } fn to_scim_value(&self) -> Option { - Some(ScimResolveStatus::Resolved(ScimValueKanidm::from( - self.map - .iter() - .flat_map(|(claim_name, mappings)| { - mappings - .values - .iter() - .map(|(group_uuid, claim_values)| ScimOAuth2ClaimMap { - group: *group_uuid, - claim: claim_name.to_string(), - join_char: mappings.join.to_str().to_string(), - values: claim_values.clone(), - }) + let unresolved_maps = self + .map + .iter() + .flat_map(|(claim_name, mappings)| { + mappings.values.iter().map(|(group_uuid, claim_values)| { + UnresolvedScimValueOauth2ClaimMap { + group_uuid: *group_uuid, + claim: claim_name.to_string(), + join_char: mappings.join.into(), + values: claim_values.clone(), + } }) - .collect::>(), - ))) + }) + .collect::>(); + + Some(ScimResolveStatus::NeedsResolution( + ScimValueIntermediate::Oauth2ClaimMap(unresolved_maps), + )) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -704,8 +870,7 @@ impl ValueSetT for ValueSetOauthClaimMap { #[cfg(test)] mod tests { use super::{ValueSetOauthClaimMap, ValueSetOauthScope, ValueSetOauthScopeMap}; - use crate::prelude::ValueSet; - use crate::valueset::ValueSetT; + use crate::prelude::*; use std::collections::BTreeSet; #[test] @@ -726,43 +891,88 @@ mod tests { #[test] fn test_scim_oauth2_scope() { let vs: ValueSet = ValueSetOauthScope::new("fully_sick_scope_m8".to_string()); - let data = r#""fully_sick_scope_m8""#; - crate::valueset::scim_json_reflexive(vs, data); + let data = r#"["fully_sick_scope_m8"]"#; + crate::valueset::scim_json_reflexive(vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } - #[test] - fn test_scim_oauth2_scope_map() { - let u = uuid::uuid!("3a163ca0-4762-4620-a188-06b750c84c86"); + #[qs_test] + async fn test_scim_oauth2_scope_map(server: &QueryServer) { + let mut write_txn = server.write(duration_from_epoch_now()).await.unwrap(); + + let g_uuid = uuid::uuid!("4d21d04a-dc0e-42eb-b850-34dd180b107f"); + assert!(write_txn + .internal_create(vec![entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup")), + (Attribute::Uuid, Value::Uuid(g_uuid)) + ),]) + .is_ok()); + let set = ["read".to_string(), "write".to_string()].into(); - let vs: ValueSet = ValueSetOauthScopeMap::new(u, set); + let vs: ValueSet = ValueSetOauthScopeMap::new(g_uuid, set); let data = r#" [ { - "scopes": "read write", - "uuid": "3a163ca0-4762-4620-a188-06b750c84c86" + "scopes": ["read", "write"], + "group": "testgroup@example.com", + "groupUuid": "4d21d04a-dc0e-42eb-b850-34dd180b107f" } ] "#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive_unresolved(&mut write_txn, vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive_unresolved::( + &mut write_txn, + vs, + &[], + ); + + assert!(write_txn.commit().is_ok()); } - #[test] - fn test_scim_oauth2_claim_map() { - let u = uuid::uuid!("3a163ca0-4762-4620-a188-06b750c84c86"); + #[qs_test] + async fn test_scim_oauth2_claim_map(server: &QueryServer) { + let mut write_txn = server.write(duration_from_epoch_now()).await.unwrap(); + + let g_uuid = uuid::uuid!("4d21d04a-dc0e-42eb-b850-34dd180b107f"); + assert!(write_txn + .internal_create(vec![entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup")), + (Attribute::Uuid, Value::Uuid(g_uuid)) + ),]) + .is_ok()); + let set = ["read".to_string(), "write".to_string()].into(); - let vs: ValueSet = ValueSetOauthClaimMap::new_value("claim".to_string(), u, set); + let vs: ValueSet = ValueSetOauthClaimMap::new_value("claim".to_string(), g_uuid, set); let data = r#" [ { "claim": "claim", - "group": "3a163ca0-4762-4620-a188-06b750c84c86", + "group": "testgroup@example.com", + "groupUuid": "4d21d04a-dc0e-42eb-b850-34dd180b107f", "joinChar": ";", - "values": "read write" + "values": ["read", "write"] } ] "#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive_unresolved(&mut write_txn, vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive_unresolved::( + &mut write_txn, + vs, + &[], + ); + + assert!(write_txn.commit().is_ok()); } } diff --git a/server/lib/src/valueset/session.rs b/server/lib/src/valueset/session.rs index f98693968..84d350dd2 100644 --- a/server/lib/src/valueset/session.rs +++ b/server/lib/src/valueset/session.rs @@ -1,8 +1,3 @@ -use std::collections::btree_map::Entry as BTreeEntry; -use std::collections::BTreeMap; - -use time::OffsetDateTime; - use crate::be::dbvalue::{ DbCidV1, DbValueAccessScopeV1, DbValueApiToken, DbValueApiTokenScopeV1, DbValueAuthTypeV1, DbValueIdentityId, DbValueOauth2Session, DbValueSession, DbValueSessionStateV1, @@ -14,10 +9,12 @@ use crate::value::{ ApiToken, ApiTokenScope, AuthType, Oauth2Session, Session, SessionScope, SessionState, }; use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ScimResolveStatus, ValueSet}; - use kanidm_proto::scim_v1::server::ScimApiToken; use kanidm_proto::scim_v1::server::ScimAuthSession; use kanidm_proto::scim_v1::server::ScimOAuth2Session; +use std::collections::btree_map::Entry as BTreeEntry; +use std::collections::BTreeMap; +use time::OffsetDateTime; #[derive(Debug, Clone)] pub struct ValueSetSession { diff --git a/server/lib/src/valueset/ssh.rs b/server/lib/src/valueset/ssh.rs index 15ab13185..aa9bd6f7e 100644 --- a/server/lib/src/valueset/ssh.rs +++ b/server/lib/src/valueset/ssh.rs @@ -1,15 +1,15 @@ -use std::collections::btree_map::Entry as BTreeEntry; -use std::collections::BTreeMap; - use crate::be::dbvalue::DbValueTaggedStringV1; use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::JsonValue; +use kanidm_proto::scim_v1::ScimSshPublicKey; use sshkey_attest::proto::PublicKey as SshPublicKey; - -use kanidm_proto::scim_v1::server::ScimSshPublicKey; +use std::collections::btree_map::Entry as BTreeEntry; +use std::collections::BTreeMap; #[derive(Debug, Clone)] pub struct ValueSetSshKey { @@ -54,6 +54,24 @@ impl ValueSetSshKey { } } +impl ValueSetScimPut for ValueSetSshKey { + fn from_scim_json_put(value: JsonValue) -> Result { + let value: Vec = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Ssh Public Key syntax invalid"); + OperationError::SC0024SshPublicKeySyntaxInvalid + })?; + + let map = value + .into_iter() + .map(|ScimSshPublicKey { label, value }| (label, value)) + .collect(); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetSshKey { + map, + }))) + } +} + impl ValueSetT for ValueSetSshKey { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -229,6 +247,9 @@ mod tests { } ] "#; - crate::valueset::scim_json_reflexive(vs, data); + crate::valueset::scim_json_reflexive(vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/syntax.rs b/server/lib/src/valueset/syntax.rs index 248b40cf8..53df1c77b 100644 --- a/server/lib/src/valueset/syntax.rs +++ b/server/lib/src/valueset/syntax.rs @@ -1,7 +1,9 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; #[derive(Debug, Clone)] @@ -27,6 +29,29 @@ impl ValueSetSyntax { } } +impl ValueSetScimPut for ValueSetSyntax { + fn from_scim_json_put(value: JsonValue) -> Result { + let value = serde_json::from_value::(value) + .map_err(|err| { + error!(?err, "SCIM SyntaxType syntax invalid"); + OperationError::SC0008SyntaxTypeSyntaxInvalid + }) + .and_then(|value| { + SyntaxType::try_from(value.as_str()).map_err(|()| { + error!("SCIM SyntaxType syntax invalid - value"); + OperationError::SC0008SyntaxTypeSyntaxInvalid + }) + })?; + + let mut set = SmolSet::new(); + set.insert(value); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetSyntax { + set, + }))) + } +} + impl FromIterator for Option> { fn from_iter(iter: T) -> Option> where @@ -106,9 +131,10 @@ impl ValueSetT for ValueSetSyntax { } fn to_scim_value(&self) -> Option { - Some(ScimResolveStatus::Resolved(ScimValueKanidm::from( - self.set.iter().map(|u| u.to_string()).collect::>(), - ))) + self.set + .iter() + .next() + .map(|u| ScimResolveStatus::Resolved(ScimValueKanidm::from(u.to_string()))) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -162,6 +188,9 @@ mod tests { #[test] fn test_scim_syntax() { let vs: ValueSet = ValueSetSyntax::new(SyntaxType::Uuid); - crate::valueset::scim_json_reflexive(vs, r#"["UUID"]"#); + crate::valueset::scim_json_reflexive(vs.clone(), r#""UUID""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/totp.rs b/server/lib/src/valueset/totp.rs index 26a2d15a7..e76c1a3b5 100644 --- a/server/lib/src/valueset/totp.rs +++ b/server/lib/src/valueset/totp.rs @@ -1,12 +1,10 @@ +use crate::be::dbvalue::DbTotpV1; use crate::credential::totp::Totp; use crate::prelude::*; - -use std::collections::btree_map::Entry as BTreeEntry; -use std::collections::BTreeMap; - -use crate::be::dbvalue::DbTotpV1; use crate::schema::SchemaAttribute; use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; +use std::collections::btree_map::Entry as BTreeEntry; +use std::collections::BTreeMap; #[derive(Debug, Clone)] pub struct ValueSetTotpSecret { diff --git a/server/lib/src/valueset/uihint.rs b/server/lib/src/valueset/uihint.rs index 119df288b..e00a8d588 100644 --- a/server/lib/src/valueset/uihint.rs +++ b/server/lib/src/valueset/uihint.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeSet; - use crate::prelude::*; use crate::schema::SchemaAttribute; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; use kanidm_proto::internal::UiHint; +use kanidm_proto::scim_v1::JsonValue; +use std::collections::BTreeSet; #[derive(Debug, Clone)] pub struct ValueSetUiHint { @@ -29,6 +30,21 @@ impl ValueSetUiHint { } } +impl ValueSetScimPut for ValueSetUiHint { + fn from_scim_json_put(value: JsonValue) -> Result { + let value = serde_json::from_value::>(value).map_err(|err| { + error!(?err, "SCIM UiHint syntax invalid"); + OperationError::SC0025UiHintSyntaxInvalid + })?; + + let set = value.into_iter().collect(); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetUiHint { + set, + }))) + } +} + impl ValueSetT for ValueSetUiHint { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -96,7 +112,7 @@ impl ValueSetT for ValueSetUiHint { fn to_scim_value(&self) -> Option { Some(ScimResolveStatus::Resolved(ScimValueKanidm::from( - self.set.iter().map(|u| u.to_string()).collect::>(), + self.set.iter().copied().collect::>(), ))) } @@ -147,6 +163,9 @@ mod tests { #[test] fn test_scim_uihint() { let vs: ValueSet = ValueSetUiHint::new(UiHint::PosixAccount); - crate::valueset::scim_json_reflexive(vs, r#"["PosixAccount"]"#); + crate::valueset::scim_json_reflexive(vs.clone(), r#"["posixaccount"]"#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/uint32.rs b/server/lib/src/valueset/uint32.rs index 2a53fd445..410db12e9 100644 --- a/server/lib/src/valueset/uint32.rs +++ b/server/lib/src/valueset/uint32.rs @@ -1,7 +1,9 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; #[derive(Debug, Clone)] @@ -37,6 +39,22 @@ impl ValueSetUint32 { } } +impl ValueSetScimPut for ValueSetUint32 { + fn from_scim_json_put(value: JsonValue) -> Result { + let value: u32 = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM uint32 syntax invalid"); + OperationError::SC0006Uint32SyntaxInvalid + })?; + + let mut set = SmolSet::new(); + set.insert(value); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetUint32 { + set, + }))) + } +} + impl ValueSetT for ValueSetUint32 { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -178,6 +196,9 @@ mod tests { #[test] fn test_scim_uint32() { let vs: ValueSet = ValueSetUint32::new(69); - crate::valueset::scim_json_reflexive(vs, "69"); + crate::valueset::scim_json_reflexive(vs.clone(), "69"); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/url.rs b/server/lib/src/valueset/url.rs index 20d8c7f33..eece0dacb 100644 --- a/server/lib/src/valueset/url.rs +++ b/server/lib/src/valueset/url.rs @@ -1,7 +1,9 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; #[derive(Debug, Clone)] @@ -37,6 +39,22 @@ impl ValueSetUrl { } } +impl ValueSetScimPut for ValueSetUrl { + fn from_scim_json_put(value: JsonValue) -> Result { + let value: Url = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM URL syntax invalid"); + OperationError::SC0007UrlSyntaxInvalid + })?; + + let mut set = SmolSet::new(); + set.insert(value); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetUrl { + set, + }))) + } +} + impl ValueSetT for ValueSetUrl { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -165,6 +183,9 @@ mod tests { fn test_scim_url() { let u = Url::parse("https://idm.example.com").unwrap(); let vs: ValueSet = ValueSetUrl::new(u); - crate::valueset::scim_json_reflexive(vs, r#""https://idm.example.com/""#); + crate::valueset::scim_json_reflexive(vs.clone(), r#""https://idm.example.com/""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/utf8.rs b/server/lib/src/valueset/utf8.rs index e59f6aafd..89a704aa7 100644 --- a/server/lib/src/valueset/utf8.rs +++ b/server/lib/src/valueset/utf8.rs @@ -1,8 +1,11 @@ use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::utils::trigraph_iter; -use crate::valueset::{DbValueSetV2, ScimResolveStatus, ValueSet}; - +use crate::valueset::{ + DbValueSetV2, ScimResolveStatus, ValueSet, ValueSetResolveStatus, ValueSetScimPut, +}; +use kanidm_proto::scim_v1::client::ScimStrings; +use kanidm_proto::scim_v1::JsonValue; use std::collections::BTreeSet; #[derive(Debug, Clone)] @@ -27,6 +30,21 @@ impl ValueSetUtf8 { } } +impl ValueSetScimPut for ValueSetUtf8 { + fn from_scim_json_put(value: JsonValue) -> Result { + let ScimStrings(values) = serde_json::from_value(value).map_err(|err| { + error!(?err, "SCIM Utf8 Syntax Invalid"); + OperationError::SC0026Utf8SyntaxInvalid + })?; + + let set = values.into_iter().collect(); + + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetUtf8 { + set, + }))) + } +} + impl ValueSetT for ValueSetUtf8 { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -227,6 +245,10 @@ mod tests { #[test] fn test_scim_utf8() { let vs: ValueSet = ValueSetUtf8::new("Test".to_string()); - crate::valueset::scim_json_reflexive(vs, r#""Test""#); + // Test that the output json matches some known str + crate::valueset::scim_json_reflexive(vs.clone(), r#""Test""#); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } } diff --git a/server/lib/src/valueset/uuid.rs b/server/lib/src/valueset/uuid.rs index d277ba734..1c3e109e6 100644 --- a/server/lib/src/valueset/uuid.rs +++ b/server/lib/src/valueset/uuid.rs @@ -1,11 +1,12 @@ -use std::collections::BTreeSet; - use crate::prelude::*; use crate::schema::SchemaAttribute; use crate::valueset::{ uuid_to_proto_string, DbValueSetV2, ScimResolveStatus, ScimValueIntermediate, ValueSet, + ValueSetIntermediate, ValueSetResolveStatus, ValueSetScimPut, }; +use kanidm_proto::scim_v1::JsonValue; use smolset::SmolSet; +use std::collections::BTreeSet; #[derive(Debug, Clone)] pub struct ValueSetUuid { @@ -40,6 +41,21 @@ impl ValueSetUuid { } } +impl ValueSetScimPut for ValueSetUuid { + fn from_scim_json_put(value: JsonValue) -> Result { + let uuid: Uuid = serde_json::from_value(value).map_err(|err| { + warn!(?err, "Invalid SCIM Uuid syntax"); + OperationError::SC0004UuidSyntaxInvalid + })?; + + let mut set = SmolSet::new(); + set.insert(uuid); + Ok(ValueSetResolveStatus::Resolved(Box::new(ValueSetUuid { + set, + }))) + } +} + impl ValueSetT for ValueSetUuid { fn insert_checked(&mut self, value: Value) -> Result { match value { @@ -119,7 +135,8 @@ impl ValueSetT for ValueSetUuid { .iter() .next() .copied() - .map(|uuid| ScimResolveStatus::NeedsResolution(ScimValueIntermediate::Refer(uuid))) + .map(ScimValueKanidm::Uuid) + .map(ScimResolveStatus::Resolved) } fn to_db_valueset_v2(&self) -> DbValueSetV2 { @@ -211,6 +228,55 @@ impl ValueSetRefer { Some(Box::new(ValueSetRefer { set })) } } + + pub(crate) fn from_set(set: BTreeSet) -> ValueSet { + Box::new(ValueSetRefer { set }) + } +} + +impl ValueSetScimPut for ValueSetRefer { + fn from_scim_json_put(value: JsonValue) -> Result { + use kanidm_proto::scim_v1::client::{ScimReference, ScimReferences}; + + let scim_refs: ScimReferences = serde_json::from_value(value).map_err(|err| { + warn!(?err, "Invalid SCIM reference set syntax"); + OperationError::SC0002ReferenceSyntaxInvalid + })?; + + let mut resolved = BTreeSet::default(); + let mut unresolved = Vec::with_capacity(scim_refs.len()); + + for scim_ref in scim_refs.into_iter() { + match scim_ref { + ScimReference { + uuid: None, + value: None, + } => { + warn!("Invalid SCIM reference set syntax, uuid and value are both unset."); + return Err(OperationError::SC0002ReferenceSyntaxInvalid); + } + ScimReference { + uuid: Some(uuid), .. + } => { + resolved.insert(uuid); + } + ScimReference { + value: Some(val), .. + } => { + unresolved.push(val); + } + } + } + + // We may not actually need to resolve anything, but to make tests easier we + // always return that we need resolution. + Ok(ValueSetResolveStatus::NeedsResolution( + ValueSetIntermediate::References { + resolved, + unresolved, + }, + )) + } } impl ValueSetT for ValueSetRefer { @@ -290,7 +356,7 @@ impl ValueSetT for ValueSetRefer { fn to_scim_value(&self) -> Option { let uuids = self.set.iter().copied().collect::>(); Some(ScimResolveStatus::NeedsResolution( - ScimValueIntermediate::ReferMany(uuids), + ScimValueIntermediate::References(uuids), )) } @@ -348,23 +414,50 @@ impl ValueSetT for ValueSetRefer { #[cfg(test)] mod tests { use super::{ValueSetRefer, ValueSetUuid}; - use crate::prelude::ValueSet; + use crate::prelude::*; #[test] fn test_scim_uuid() { let vs: ValueSet = ValueSetUuid::new(uuid::uuid!("4d21d04a-dc0e-42eb-b850-34dd180b107f")); - let data = r#"{"Refer": "4d21d04a-dc0e-42eb-b850-34dd180b107f"}"#; + let data = r#""4d21d04a-dc0e-42eb-b850-34dd180b107f""#; - crate::valueset::scim_json_reflexive_unresolved(vs, data); + crate::valueset::scim_json_reflexive(vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive::(vs, &[]) } - #[test] - fn test_scim_refer() { - let vs: ValueSet = ValueSetRefer::new(uuid::uuid!("4d21d04a-dc0e-42eb-b850-34dd180b107f")); + #[qs_test] + async fn test_scim_refer(server: &QueryServer) { + let mut write_txn = server.write(duration_from_epoch_now()).await.unwrap(); - let data = r#"{"ReferMany": ["4d21d04a-dc0e-42eb-b850-34dd180b107f"]}"#; + let t_uuid = uuid::uuid!("4d21d04a-dc0e-42eb-b850-34dd180b107f"); + assert!(write_txn + .internal_create(vec![entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Name, Value::new_iname("testperson1")), + (Attribute::Uuid, Value::Uuid(t_uuid)), + (Attribute::Description, Value::new_utf8s("testperson1")), + (Attribute::DisplayName, Value::new_utf8s("testperson1")) + ),]) + .is_ok()); - crate::valueset::scim_json_reflexive_unresolved(vs, data); + let vs: ValueSet = ValueSetRefer::new(t_uuid); + + let data = r#"[{"uuid": "4d21d04a-dc0e-42eb-b850-34dd180b107f", "value": "testperson1@example.com"}]"#; + + crate::valueset::scim_json_reflexive_unresolved(&mut write_txn, vs.clone(), data); + + // Test that we can parse json values into a valueset. + crate::valueset::scim_json_put_reflexive_unresolved::( + &mut write_txn, + vs, + &[], + ); + + assert!(write_txn.commit().is_ok()); } } diff --git a/server/testkit/tests/oauth2_test.rs b/server/testkit/tests/oauth2_test.rs index 7e2d2172c..aa085f4e3 100644 --- a/server/testkit/tests/oauth2_test.rs +++ b/server/testkit/tests/oauth2_test.rs @@ -365,7 +365,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { .expect("Unable to decode AccessTokenIntrospectResponse"); assert!(tir.active); - assert!(tir.scope.is_some()); + assert!(!tir.scope.is_empty()); assert_eq!(tir.client_id.as_deref(), Some(TEST_INTEGRATION_RS_ID)); assert_eq!( tir.username.as_deref(), @@ -469,7 +469,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { .expect("Unable to decode AccessTokenIntrospectResponse"); assert!(tir.active); - assert!(tir.scope.is_some()); + assert!(!tir.scope.is_empty()); assert_eq!(tir.client_id.as_deref(), Some(TEST_INTEGRATION_RS_ID)); assert_eq!(tir.username.as_deref(), Some("test_integration@localhost")); assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));