diff --git a/libs/client/src/oauth.rs b/libs/client/src/oauth.rs index 1b45f2c4c..2d0198542 100644 --- a/libs/client/src/oauth.rs +++ b/libs/client/src/oauth.rs @@ -1,10 +1,10 @@ use crate::{ClientError, KanidmClient}; use kanidm_proto::constants::{ - ATTR_DISPLAYNAME, ATTR_ES256_PRIVATE_KEY_DER, ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, - ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, - ATTR_OAUTH2_PREFER_SHORT_USERNAME, ATTR_OAUTH2_RS_BASIC_SECRET, ATTR_OAUTH2_RS_NAME, - ATTR_OAUTH2_RS_ORIGIN, ATTR_OAUTH2_RS_ORIGIN_LANDING, ATTR_OAUTH2_RS_TOKEN_KEY, - ATTR_RS256_PRIVATE_KEY_DER, + ATTR_DISPLAYNAME, ATTR_ES256_PRIVATE_KEY_DER, ATTR_NAME, + ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT, + ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE, ATTR_OAUTH2_PREFER_SHORT_USERNAME, + ATTR_OAUTH2_RS_BASIC_SECRET, ATTR_OAUTH2_RS_ORIGIN, ATTR_OAUTH2_RS_ORIGIN_LANDING, + ATTR_OAUTH2_RS_TOKEN_KEY, ATTR_RS256_PRIVATE_KEY_DER, }; use kanidm_proto::internal::{ImageValue, Oauth2ClaimMapJoin}; use kanidm_proto::v1::Entry; @@ -27,7 +27,7 @@ impl KanidmClient { let mut new_oauth2_rs = Entry::default(); new_oauth2_rs .attrs - .insert(ATTR_OAUTH2_RS_NAME.to_string(), vec![name.to_string()]); + .insert(ATTR_NAME.to_string(), vec![name.to_string()]); new_oauth2_rs .attrs .insert(ATTR_DISPLAYNAME.to_string(), vec![displayname.to_string()]); @@ -47,7 +47,7 @@ impl KanidmClient { let mut new_oauth2_rs = Entry::default(); new_oauth2_rs .attrs - .insert(ATTR_OAUTH2_RS_NAME.to_string(), vec![name.to_string()]); + .insert(ATTR_NAME.to_string(), vec![name.to_string()]); new_oauth2_rs .attrs .insert(ATTR_DISPLAYNAME.to_string(), vec![displayname.to_string()]); @@ -91,7 +91,7 @@ impl KanidmClient { if let Some(newname) = name { update_oauth2_rs .attrs - .insert(ATTR_OAUTH2_RS_NAME.to_string(), vec![newname.to_string()]); + .insert(ATTR_NAME.to_string(), vec![newname.to_string()]); } if let Some(newdisplayname) = displayname { update_oauth2_rs.attrs.insert( diff --git a/proto/src/constants.rs b/proto/src/constants.rs index 6ebc1079d..c91796b5c 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -145,6 +145,7 @@ pub const ATTR_PRIVATE_COOKIE_KEY: &str = "private_cookie_key"; pub const ATTR_PRIVILEGE_EXPIRY: &str = "privilege_expiry"; pub const ATTR_RADIUS_SECRET: &str = "radius_secret"; pub const ATTR_RECYCLED: &str = "recycled"; +pub const ATTR_RECYCLEDDIRECTMEMBEROF: &str = "recycled_directmemberof"; pub const ATTR_REPLICATED: &str = "replicated"; pub const ATTR_RS256_PRIVATE_KEY_DER: &str = "rs256_private_key_der"; pub const ATTR_SCOPE: &str = "scope"; diff --git a/proto/src/oauth2.rs b/proto/src/oauth2.rs index 8f79e4b96..7ffb5852a 100644 --- a/proto/src/oauth2.rs +++ b/proto/src/oauth2.rs @@ -85,6 +85,10 @@ pub enum GrantTypeReq { redirect_uri: Url, code_verifier: Option, }, + ClientCredentials { + #[serde_as(as = "Option>")] + scope: Option>, + }, RefreshToken { refresh_token: String, #[serde_as(as = "Option>")] diff --git a/pykanidm/kanidm/models/oauth2_rs.py b/pykanidm/kanidm/models/oauth2_rs.py index d8a74d9c0..1dd836a84 100644 --- a/pykanidm/kanidm/models/oauth2_rs.py +++ b/pykanidm/kanidm/models/oauth2_rs.py @@ -10,8 +10,8 @@ class OAuth2Rs(BaseModel): classes: List[str] displayname: str es256_private_key_der: str + name: str oauth2_rs_basic_secret: str - oauth2_rs_name: str oauth2_rs_origin: str oauth2_rs_token_key: str oauth2_rs_sup_scope_map: List[str] @@ -27,8 +27,8 @@ class RawOAuth2Rs(BaseModel): required_fields = ( "displayname", "es256_private_key_der", + "name", "oauth2_rs_basic_secret", - "oauth2_rs_name", "oauth2_rs_origin", "oauth2_rs_token_key", ) @@ -42,8 +42,8 @@ class RawOAuth2Rs(BaseModel): classes=self.attrs["class"], displayname=self.attrs["displayname"][0], es256_private_key_der=self.attrs["es256_private_key_der"][0], + name=self.attrs["name"][0], oauth2_rs_basic_secret=self.attrs["oauth2_rs_basic_secret"][0], - oauth2_rs_name=self.attrs["oauth2_rs_name"][0], oauth2_rs_origin=self.attrs["oauth2_rs_origin"][0], oauth2_rs_token_key=self.attrs["oauth2_rs_token_key"][0], oauth2_rs_sup_scope_map=self.attrs.get("oauth2_rs_sup_scope_map", []), diff --git a/server/core/src/https/oauth2.rs b/server/core/src/https/oauth2.rs index 40d2b822e..84339c23b 100644 --- a/server/core/src/https/oauth2.rs +++ b/server/core/src/https/oauth2.rs @@ -75,7 +75,7 @@ impl IntoResponse for HTTPOauth2Error { pub(crate) fn oauth2_id(rs_name: &str) -> Filter { filter_all!(f_and!([ f_eq(Attribute::Class, EntryClass::OAuth2ResourceServer.into()), - f_eq(Attribute::OAuth2RsName, PartialValue::new_iname(rs_name)) + f_eq(Attribute::Name, PartialValue::new_iname(rs_name)) ])) } diff --git a/server/core/src/https/v1_oauth2.rs b/server/core/src/https/v1_oauth2.rs index 3d69f8b8b..fe7946d4c 100644 --- a/server/core/src/https/v1_oauth2.rs +++ b/server/core/src/https/v1_oauth2.rs @@ -57,6 +57,7 @@ pub(crate) async fn oauth2_basic_post( let classes = vec![ EntryClass::OAuth2ResourceServer.to_string(), EntryClass::OAuth2ResourceServerBasic.to_string(), + EntryClass::Account.to_string(), EntryClass::Object.to_string(), ]; json_rest_event_post(state, classes, obj, kopid, client_auth_info).await @@ -82,6 +83,7 @@ pub(crate) async fn oauth2_public_post( let classes = vec![ EntryClass::OAuth2ResourceServer.to_string(), EntryClass::OAuth2ResourceServerPublic.to_string(), + EntryClass::Account.to_string(), EntryClass::Object.to_string(), ]; json_rest_event_post(state, classes, obj, kopid, client_auth_info).await diff --git a/server/core/src/repl/mod.rs b/server/core/src/repl/mod.rs index 16145a013..1c6afd7b4 100644 --- a/server/core/src/repl/mod.rs +++ b/server/core/src/repl/mod.rs @@ -617,7 +617,7 @@ async fn repl_acceptor( }; // Setup a broadcast to control our tasks. - let (task_tx, task_rx1) = broadcast::channel(2); + let (task_tx, task_rx1) = broadcast::channel(1); // Note, we drop this task here since each task will re-subscribe. That way the // broadcast doesn't jam up because we aren't draining this task. drop(task_rx1); diff --git a/server/lib/src/be/dbvalue.rs b/server/lib/src/be/dbvalue.rs index f919e4c3e..8c5e45a46 100644 --- a/server/lib/src/be/dbvalue.rs +++ b/server/lib/src/be/dbvalue.rs @@ -544,6 +544,7 @@ pub enum DbValueApiToken { }, } +#[skip_serializing_none] #[derive(Serialize, Deserialize, Debug)] pub enum DbValueOauth2Session { V1 { @@ -570,6 +571,18 @@ pub enum DbValueOauth2Session { #[serde(rename = "r")] rs_uuid: Uuid, }, + V3 { + #[serde(rename = "u")] + refer: Uuid, + #[serde(rename = "p")] + parent: Option, + #[serde(rename = "e")] + state: DbValueSessionStateV1, + #[serde(rename = "i")] + issued_at: String, + #[serde(rename = "r")] + rs_uuid: Uuid, + }, } // Internal representation of an image diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index cf7bf6f59..ec04de5a1 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -720,6 +720,108 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_OAUTH2_MANAGE_DL5: BuiltinAcp = BuiltinAcp { + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlCreate, + EntryClass::AccessControlDelete, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_hp_oauth2_manage_priv", + uuid: UUID_IDM_ACP_OAUTH2_MANAGE_V1, + description: "Builtin IDM Control for managing oauth2 resource server integrations.", + receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_OAUTH2_ADMINS]), + target: BuiltinAcpTarget::Filter(ProtoFilter::And(vec![ + match_class_filter!(EntryClass::OAuth2ResourceServer), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone(), + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Description, + Attribute::DisplayName, + Attribute::Name, + Attribute::Spn, + Attribute::OAuth2Session, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsBasicSecret, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + modify_removed_attrs: vec![ + Attribute::Description, + Attribute::DisplayName, + Attribute::Name, + Attribute::OAuth2Session, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsBasicSecret, + Attribute::OAuth2RsTokenKey, + Attribute::Es256PrivateKeyDer, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::Rs256PrivateKeyDer, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + modify_present_attrs: vec![ + Attribute::Description, + Attribute::DisplayName, + Attribute::Name, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + create_attrs: vec![ + Attribute::Class, + Attribute::Description, + Attribute::Name, + Attribute::OAuth2RsName, + Attribute::OAuth2RsOrigin, + Attribute::OAuth2RsOriginLanding, + Attribute::OAuth2RsSupScopeMap, + Attribute::OAuth2RsScopeMap, + Attribute::OAuth2AllowInsecureClientDisablePkce, + Attribute::OAuth2JwtLegacyCryptoEnable, + Attribute::OAuth2PreferShortUsername, + Attribute::OAuth2AllowLocalhostRedirect, + Attribute::OAuth2RsClaimMap, + Attribute::Image, + ], + create_classes: vec![ + EntryClass::Object, + EntryClass::Account, + EntryClass::OAuth2ResourceServer, + EntryClass::OAuth2ResourceServerBasic, + EntryClass::OAuth2ResourceServerPublic, + ], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_DOMAIN_ADMIN_V1: BuiltinAcp = BuiltinAcp { classes: vec![ diff --git a/server/lib/src/constants/entries.rs b/server/lib/src/constants/entries.rs index 971dd1e8d..3d4c75953 100644 --- a/server/lib/src/constants/entries.rs +++ b/server/lib/src/constants/entries.rs @@ -141,6 +141,7 @@ pub enum Attribute { PrivateCookieKey, PrivilegeExpiry, RadiusSecret, + RecycledDirectMemberOf, Replicated, Rs256PrivateKeyDer, Scope, @@ -329,6 +330,7 @@ impl TryFrom for Attribute { ATTR_PRIVATE_COOKIE_KEY => Attribute::PrivateCookieKey, ATTR_PRIVILEGE_EXPIRY => Attribute::PrivilegeExpiry, ATTR_RADIUS_SECRET => Attribute::RadiusSecret, + ATTR_RECYCLEDDIRECTMEMBEROF => Attribute::RecycledDirectMemberOf, ATTR_REPLICATED => Attribute::Replicated, ATTR_RS256_PRIVATE_KEY_DER => Attribute::Rs256PrivateKeyDer, ATTR_SCOPE => Attribute::Scope, @@ -492,6 +494,7 @@ impl From for &'static str { Attribute::PrivateCookieKey => ATTR_PRIVATE_COOKIE_KEY, Attribute::PrivilegeExpiry => ATTR_PRIVILEGE_EXPIRY, Attribute::RadiusSecret => ATTR_RADIUS_SECRET, + Attribute::RecycledDirectMemberOf => ATTR_RECYCLEDDIRECTMEMBEROF, Attribute::Replicated => ATTR_REPLICATED, Attribute::Rs256PrivateKeyDer => ATTR_RS256_PRIVATE_KEY_DER, Attribute::Scope => ATTR_SCOPE, @@ -826,7 +829,6 @@ impl From for EntryInitNew { } lazy_static! { - /// Builtin System Admin account. pub static ref BUILTIN_ACCOUNT_ADMIN: BuiltinAccount = BuiltinAccount { account_type: AccountType::ServiceAccount, @@ -863,12 +865,18 @@ pub const UUID_TESTPERSON_2: Uuid = uuid!("538faac7-4d29-473b-a59d-23023ac19955" lazy_static! { pub static ref E_TESTPERSON_1: EntryInitNew = 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::DisplayName, Value::new_utf8s("Test Person 1")), (Attribute::Uuid, Value::Uuid(UUID_TESTPERSON_1)) ); pub static ref E_TESTPERSON_2: EntryInitNew = 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("testperson2")), + (Attribute::DisplayName, Value::new_utf8s("Test Person 2")), (Attribute::Uuid, Value::Uuid(UUID_TESTPERSON_2)) ); } diff --git a/server/lib/src/constants/mod.rs b/server/lib/src/constants/mod.rs index 186afad7e..bfad47ff4 100644 --- a/server/lib/src/constants/mod.rs +++ b/server/lib/src/constants/mod.rs @@ -43,16 +43,18 @@ pub const SYSTEM_INDEX_VERSION: i64 = 30; */ pub type DomainVersion = u32; +pub const DOMAIN_LEVEL_0: DomainVersion = 0; pub const DOMAIN_LEVEL_1: DomainVersion = 1; pub const DOMAIN_LEVEL_2: DomainVersion = 2; pub const DOMAIN_LEVEL_3: DomainVersion = 3; pub const DOMAIN_LEVEL_4: DomainVersion = 4; +pub const DOMAIN_LEVEL_5: DomainVersion = 5; // The minimum supported domain functional level -pub const DOMAIN_MIN_LEVEL: DomainVersion = DOMAIN_LEVEL_2; +pub const DOMAIN_MIN_LEVEL: DomainVersion = DOMAIN_LEVEL_5; // The target supported domain functional level -pub const DOMAIN_TGT_LEVEL: DomainVersion = DOMAIN_LEVEL_4; +pub const DOMAIN_TGT_LEVEL: DomainVersion = DOMAIN_LEVEL_5; // The maximum supported domain functional level -pub const DOMAIN_MAX_LEVEL: DomainVersion = DOMAIN_LEVEL_4; +pub const DOMAIN_MAX_LEVEL: DomainVersion = DOMAIN_LEVEL_5; // On test builds, define to 60 seconds #[cfg(test)] @@ -65,15 +67,15 @@ pub const PURGE_FREQUENCY: u64 = 600; /// In test, we limit the changelog to 10 minutes. pub const CHANGELOG_MAX_AGE: u64 = 600; #[cfg(not(test))] -/// A replica may be less than 1 day out of sync and catch up. -pub const CHANGELOG_MAX_AGE: u64 = 86400; +/// A replica may be up to 7 days out of sync before being denied updates. +pub const CHANGELOG_MAX_AGE: u64 = 7 * 86400; #[cfg(test)] /// In test, we limit the recyclebin to 5 minutes. pub const RECYCLEBIN_MAX_AGE: u64 = 300; #[cfg(not(test))] /// In production we allow 1 week -pub const RECYCLEBIN_MAX_AGE: u64 = 604_800; +pub const RECYCLEBIN_MAX_AGE: u64 = 7 * 86400; // 5 minute auth session window. pub const AUTH_SESSION_TIMEOUT: u64 = 300; diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index 106b22ef0..35535e0ab 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -637,6 +637,32 @@ pub static ref SCHEMA_CLASS_PERSON: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_PERSON_DL5: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_PERSON, + name: EntryClass::Person.into(), + description: "Object representation of a person".to_string(), + + sync_allowed: true, + systemmay: vec![ + Attribute::PrimaryCredential.into(), + Attribute::PassKeys.into(), + Attribute::AttestedPasskeys.into(), + Attribute::CredentialUpdateIntentToken.into(), + Attribute::SshPublicKey.into(), + Attribute::RadiusSecret.into(), + Attribute::OAuth2ConsentScopeMap.into(), + Attribute::UserAuthTokenSession.into(), + Attribute::OAuth2Session.into(), + Attribute::Mail.into(), + Attribute::LegalName.into(), + ], + systemmust: vec![ + Attribute::IdVerificationEcKey.into() + ], + systemexcludes: vec![EntryClass::ServiceAccount.into()], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_ORGPERSON: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_ORGPERSON, name: EntryClass::OrgPerson.into(), @@ -725,7 +751,32 @@ pub static ref SCHEMA_CLASS_ACCOUNT: SchemaClass = SchemaClass { ], systemsupplements: vec![ EntryClass::Person.into(), - EntryClass::ServiceAccount.into()], + EntryClass::ServiceAccount.into(), + ], + ..Default::default() +}; + +pub static ref SCHEMA_CLASS_ACCOUNT_DL5: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_ACCOUNT, + name: EntryClass::Account.into(), + description: "Object representation of an account".to_string(), + + sync_allowed: true, + systemmay: vec![ + Attribute::AccountExpire.into(), + Attribute::AccountValidFrom.into(), + Attribute::NameHistory.into(), + ], + systemmust: vec![ + Attribute::DisplayName.into(), + Attribute::Name.into(), + Attribute::Spn.into() + ], + systemsupplements: vec![ + EntryClass::Person.into(), + EntryClass::ServiceAccount.into(), + EntryClass::OAuth2ResourceServer.into(), + ], ..Default::default() }; @@ -745,6 +796,27 @@ pub static ref SCHEMA_CLASS_SERVICE_ACCOUNT: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_SERVICE_ACCOUNT_DL5: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_SERVICE_ACCOUNT, + name: EntryClass::ServiceAccount.into(), + description: "Object representation of service account".to_string(), + + sync_allowed: true, + systemmay: vec![ + Attribute::SshPublicKey.into(), + Attribute::UserAuthTokenSession.into(), + Attribute::OAuth2Session.into(), + Attribute::Description.into(), + + Attribute::Mail.into(), + Attribute::PrimaryCredential.into(), + Attribute::JwsEs256PrivateKey.into(), + Attribute::ApiTokenSession.into(), + ], + systemexcludes: vec![EntryClass::Person.into()], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_SYNC_ACCOUNT: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_SYNC_ACCOUNT, name: EntryClass::SyncAccount.into(), @@ -877,6 +949,31 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS_DL4: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_OAUTH2_RS_DL5: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_OAUTH2_RS, + name: EntryClass::OAuth2ResourceServer.into(), + description: "The class representing a configured Oauth2 Resource Server".to_string(), + + systemmay: vec![ + Attribute::Description.into(), + Attribute::OAuth2RsScopeMap.into(), + Attribute::OAuth2RsSupScopeMap.into(), + Attribute::Rs256PrivateKeyDer.into(), + Attribute::OAuth2JwtLegacyCryptoEnable.into(), + Attribute::OAuth2PreferShortUsername.into(), + Attribute::OAuth2RsOriginLanding.into(), + Attribute::Image.into(), + Attribute::OAuth2RsClaimMap.into(), + Attribute::OAuth2Session.into(), + ], + systemmust: vec![ + Attribute::OAuth2RsOrigin.into(), + Attribute::OAuth2RsTokenKey.into(), + Attribute::Es256PrivateKeyDer.into(), + ], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC, name: EntryClass::OAuth2ResourceServerBasic.into(), @@ -888,6 +985,19 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC_DL5: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC, + name: EntryClass::OAuth2ResourceServerBasic.into(), + description: "The class representing a configured Oauth2 Resource Server authenticated with http basic authentication".to_string(), + + systemmay: vec![ + Attribute::OAuth2AllowInsecureClientDisablePkce.into(), + ], + systemmust: vec![ Attribute::OAuth2RsBasicSecret.into()], + systemexcludes: vec![ EntryClass::OAuth2ResourceServerPublic.into()], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_OAUTH2_RS_PUBLIC: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_PUBLIC, name: EntryClass::OAuth2ResourceServerPublic.into(), diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index 27518b723..c3d3e6ff6 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -274,6 +274,8 @@ pub const UUID_SCHEMA_ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT: Uuid = uuid!("00000000-0000-0000-0000-ffff00000158"); pub const UUID_SCHEMA_ATTR_OAUTH2_RS_CLAIM_MAP: Uuid = uuid!("00000000-0000-0000-0000-ffff00000159"); +pub const UUID_SCHEMA_ATTR_RECYCLEDDIRECTMEMBEROF: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000160"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/server/lib/src/entry.rs b/server/lib/src/entry.rs index b6cc7185d..49c834af2 100644 --- a/server/lib/src/entry.rs +++ b/server/lib/src/entry.rs @@ -1202,6 +1202,7 @@ impl Entry { self.remove_ava(Attribute::Class, &EntryClass::Recycled.into()); self.remove_ava(Attribute::Class, &EntryClass::Conflict.into()); self.purge_ava(Attribute::SourceUuid); + self.purge_ava(Attribute::RecycledDirectMemberOf); // Change state repl doesn't need this flag // self.valid.ecstate.revive(&self.valid.cid); @@ -2312,6 +2313,25 @@ where &self.valid.ecstate } + /// Determine if any attribute of this entry changed excluding the attribute named. + /// This allows for detection of entry changes unless the change was to a specific + /// attribute. + pub(crate) fn entry_changed_excluding_attribute(&self, attr: Attribute, cid: &Cid) -> bool { + use crate::repl::entry::State; + + match self.get_changestate().current() { + State::Live { at: _, changes } => { + changes.iter().any(|(change_attr, change_id)| { + change_id >= cid && + change_attr != attr.as_ref() && + // This always changes, and could throw off other detections. + change_attr != Attribute::LastModifiedCid.as_ref() + }) + } + State::Tombstone { at } => at == cid, + } + } + /// ⚠️ - Invalidate an entry by resetting it's change state to time-zero. This entry /// can never be replicated after this. /// This is a TEST ONLY method and will never be exposed in production. diff --git a/server/lib/src/filter.rs b/server/lib/src/filter.rs index ac32dcc1e..0fc928766 100644 --- a/server/lib/src/filter.rs +++ b/server/lib/src/filter.rs @@ -2008,8 +2008,8 @@ mod tests { let e1 = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), - (Attribute::Class, EntryClass::Person.to_value()), (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), (Attribute::Name, Value::new_iname("testperson1")), ( Attribute::Uuid, @@ -2021,7 +2021,8 @@ mod tests { let e2 = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), - (Attribute::Class, EntryClass::Person.to_value().clone()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), (Attribute::Name, Value::new_iname("testperson2")), ( Attribute::Uuid, @@ -2034,7 +2035,8 @@ mod tests { // We need to add these and then push through the state machine. let e_ts = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), - (Attribute::Class, EntryClass::Person.to_value().clone()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), (Attribute::Name, Value::new_iname("testperson3")), ( Attribute::Uuid, @@ -2082,7 +2084,7 @@ mod tests { let t_uuid = vs_refer![uuid!("a67c0c71-0b35-4218-a6b0-22d23d131d27")] as _; let r_uuid = server_txn.resolve_valueset(&t_uuid); debug!("{:?}", r_uuid); - assert!(r_uuid == Ok(vec!["testperson2".to_string()])); + assert!(r_uuid == Ok(vec!["testperson2@example.com".to_string()])); // Resolve UUID non-exist let t_uuid_non = vs_refer![uuid!("b83e98f0-3d2e-41d2-9796-d8d993289c86")] as _; diff --git a/server/lib/src/idm/applinks.rs b/server/lib/src/idm/applinks.rs index 39e306231..b7c55e89f 100644 --- a/server/lib/src/idm/applinks.rs +++ b/server/lib/src/idm/applinks.rs @@ -49,7 +49,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { .cloned()?; let name = entry - .get_ava_single_iname(Attribute::OAuth2RsName) + .get_ava_single_iname(Attribute::Name) .map(str::to_string)?; Some(AppLink::Oauth2 { @@ -84,6 +84,7 @@ mod tests { let e_rs: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -92,10 +93,7 @@ mod tests { Attribute::Class, EntryClass::OAuth2ResourceServerBasic.to_value() ), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index 5b779502e..e56a2990c 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -154,6 +154,14 @@ pub(crate) enum Oauth2TokenType { // We stash some details here for oidc. nonce: Option, }, + ClientAccess { + scopes: BTreeSet, + session_id: Uuid, + uuid: Uuid, + expiry: time::OffsetDateTime, + iat: i64, + nbf: i64, + }, } impl fmt::Display for Oauth2TokenType { @@ -165,6 +173,9 @@ impl fmt::Display for Oauth2TokenType { Oauth2TokenType::Refresh { session_id, .. } => { write!(f, "refresh_token ({session_id}) ") } + Oauth2TokenType::ClientAccess { session_id, .. } => { + write!(f, "client_access_token ({session_id})") + } } } } @@ -275,6 +286,8 @@ pub struct Oauth2RS { claim_map: BTreeMap>, scope_maps: BTreeMap>, sup_scope_maps: BTreeMap>, + client_scopes: BTreeSet, + client_sup_scopes: BTreeSet, // Our internal exchange encryption material for this rs. token_fernet: Fernet, jws_signer: Oauth2JwsSigner, @@ -409,7 +422,7 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { // Now we know we can load the shared attrs. let name = ent - .get_ava_single_iname(Attribute::OAuth2RsName) + .get_ava_single_iname(Attribute::Name) .map(str::to_string) .ok_or(OperationError::InvalidValueState)?; @@ -449,6 +462,41 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { .cloned() .unwrap_or_default(); + // From our scope maps we can now determine what scopes would be granted to our + // client during a client credentials authentication. + let (client_scopes, client_sup_scopes) = if let Some(client_member_of) = ent.get_ava_refer(Attribute::MemberOf) { + let client_scopes = + scope_maps + .iter() + .filter_map(|(u, m)| { + if client_member_of.contains(u) { + Some(m.iter()) + } else { + None + } + }) + .flatten() + .cloned() + .collect::>(); + + let client_sup_scopes = sup_scope_maps + .iter() + .filter_map(|(u, m)| { + if client_member_of.contains(u) { + Some(m.iter()) + } else { + None + } + }) + .flatten() + .cloned() + .collect::>(); + + (client_scopes, client_sup_scopes) + } else { + (BTreeSet::default(), BTreeSet::default()) + }; + let e_claim_maps = ent .get_ava_set(Attribute::OAuth2RsClaimMap) .and_then(|vs| vs.as_oauthclaim_map()); @@ -573,6 +621,8 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> { origin_https, scope_maps, sup_scope_maps, + client_scopes, + client_sup_scopes, claim_map, token_fernet, jws_signer, @@ -641,7 +691,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { .token_fernet .decrypt(&revoke_req.token) .map_err(|_| { - admin_error!("Failed to decrypt token introspection request"); + admin_error!("Failed to decrypt token revoke request"); Oauth2Error::InvalidRequest }) .and_then(|data| { @@ -660,6 +710,12 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { uuid, .. } + | Oauth2TokenType::ClientAccess { + session_id, + expiry, + uuid, + .. + } | Oauth2TokenType::Refresh { session_id, expiry, @@ -738,11 +794,13 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { }; // check the secret. - match &o2rs.type_ { + let client_authentication_valid = match &o2rs.type_ { OauthRSType::Basic { authz_secret, .. } => { match secret { Some(secret) => { - if authz_secret != &secret { + if authz_secret == &secret { + true + } else { security_info!("Invalid OAuth2 client_id secret"); return Err(Oauth2Error::AuthenticationRequired); } @@ -757,14 +815,10 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } // Relies on the token to be valid - no further action needed. - OauthRSType::Public { .. } => {} + OauthRSType::Public { .. } => false, }; // We are authenticated! Yay! Now we can actually check things ... - - // TODO: add refresh token grant type. - // If it's a refresh token grant, are the consent permissions the same? - match &token_req.grant_type { GrantTypeReq::AuthorizationCode { code, @@ -777,6 +831,16 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { code_verifier.as_deref(), ct, ), + GrantTypeReq::ClientCredentials { scope } => { + if client_authentication_valid { + self.check_oauth2_token_client_credentials(o2rs, scope.as_ref(), ct) + } else { + security_info!( + "Unable to proceed with client credentials grant unless client authentication is provided and valid" + ); + Err(Oauth2Error::AuthenticationRequired) + } + } GrantTypeReq::RefreshToken { refresh_token, scope, @@ -1011,8 +1075,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { })?; match token { - Oauth2TokenType::Access { .. } => { - admin_error!("attempt to refresh with access token"); + Oauth2TokenType::Access { .. } | Oauth2TokenType::ClientAccess { .. } => { + admin_error!("attempt to refresh with an access token"); Err(Oauth2Error::InvalidToken) } Oauth2TokenType::Refresh { @@ -1035,7 +1099,13 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { // Check the session is still valid. This call checks the parent session // and the OAuth2 session. let valid = self - .check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct) + .check_oauth2_account_uuid_valid( + uuid, + session_id, + Some(parent_session_id), + iat, + ct, + ) .map_err(|_| admin_error!("Account is not valid")); let Ok(Some(entry)) = valid else { @@ -1117,6 +1187,111 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } } + #[instrument(level = "debug", skip_all)] + fn check_oauth2_token_client_credentials( + &mut self, + o2rs: &Oauth2RS, + req_scopes: Option<&BTreeSet>, + ct: Duration, + ) -> Result { + let req_scopes = req_scopes.cloned().unwrap_or_default(); + + // Validate all request scopes have valid syntax. + validate_scopes(&req_scopes)?; + + // Of these scopes, which do we have available? + let avail_scopes: Vec = req_scopes + .intersection(&o2rs.client_scopes) + .map(|s| s.to_string()) + .collect(); + + if avail_scopes.len() != req_scopes.len() { + admin_warn!( + ident = %o2rs.name, + requested_scopes = ?req_scopes, + available_scopes = ?o2rs.client_scopes, + "Client does not have access to the requested scopes" + ); + return Err(Oauth2Error::AccessDenied); + } + + // == ready to build the access token == + + let granted_scopes = avail_scopes + .into_iter() + .chain(o2rs.client_sup_scopes.iter().cloned()) + .collect::>(); + + let odt_ct = OffsetDateTime::UNIX_EPOCH + ct; + let iat = ct.as_secs() as i64; + let expiry = odt_ct + Duration::from_secs(OAUTH2_ACCESS_TOKEN_EXPIRY as u64); + let expires_in = OAUTH2_ACCESS_TOKEN_EXPIRY; + + let session_id = Uuid::new_v4(); + + let scope = if granted_scopes.is_empty() { + None + } else { + Some(str_join(&granted_scopes)) + }; + + let uuid = o2rs.uuid; + + let access_token_raw = Oauth2TokenType::ClientAccess { + scopes: granted_scopes, + session_id, + uuid, + expiry, + iat, + nbf: iat, + }; + + let access_token_data = serde_json::to_vec(&access_token_raw).map_err(|e| { + admin_error!(err = ?e, "Unable to encode token data"); + Oauth2Error::ServerError(OperationError::SerdeJsonError) + })?; + + let access_token = o2rs + .token_fernet + .encrypt_at_time(&access_token_data, ct.as_secs()); + + // Write the session to the db + let session = Value::Oauth2Session( + session_id, + Oauth2Session { + parent: None, + state: SessionState::ExpiresAt(expiry), + issued_at: odt_ct, + rs_uuid: o2rs.uuid, + }, + ); + + // We need to create this session on the o2rs + let modlist = ModifyList::new_list(vec![Modify::Present( + Attribute::OAuth2Session.into(), + session, + )]); + + self.qs_write + .internal_modify( + &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(uuid))), + &modlist, + ) + .map_err(|e| { + admin_error!("Failed to persist OAuth2 session record {:?}", e); + Oauth2Error::ServerError(e) + })?; + + Ok(AccessTokenResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in, + refresh_token: None, + scope, + id_token: None, + }) + } + fn generate_access_token_response( &mut self, o2rs: &Oauth2RS, @@ -1271,7 +1446,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { let session = Value::Oauth2Session( session_id, Oauth2Session { - parent: parent_session_id, + parent: Some(parent_session_id), state: SessionState::ExpiresAt(refresh_expiry), issued_at: odt_ct, rs_uuid: o2rs.uuid, @@ -1338,7 +1513,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { o2rs.token_fernet .decrypt(token) .map_err(|_| { - admin_error!("Failed to decrypt token introspection request"); + admin_error!("Failed to decrypt token reflection request"); OperationError::CryptographyError }) .and_then(|data| { @@ -1495,25 +1670,8 @@ impl<'a> IdmServerProxyReadTransaction<'a> { return Err(Oauth2Error::InvalidRequest); } - let failed_scopes = req_scopes - .iter() - .filter(|&s| !OAUTHSCOPE_RE.is_match(s)) - .cloned() - .collect::>(); - if !failed_scopes.is_empty() { - let requested_scopes_string = req_scopes - .iter() - .cloned() - .collect::>() - .join(","); - admin_error!( - "Invalid OAuth2 request - requested scopes ({}) but ({}) failed to pass validation rules - all must match the regex {}", - requested_scopes_string, - failed_scopes.join(","), - OAUTHSCOPE_RE.as_str() - ); - return Err(Oauth2Error::InvalidScope); - } + // Validate all request scopes have valid syntax. + validate_scopes(&req_scopes)?; let uat_scopes: BTreeSet = o2rs .scope_maps @@ -1758,6 +1916,8 @@ impl<'a> IdmServerProxyReadTransaction<'a> { // We are authenticated! Yay! Now we can actually check things ... + let prefer_short_username = o2rs.prefer_short_username; + let token: Oauth2TokenType = o2rs .token_fernet .decrypt(&intr_req.token) @@ -1793,13 +1953,19 @@ impl<'a> IdmServerProxyReadTransaction<'a> { // Is the user expired, or the OAuth2 session invalid? let valid = self - .check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct) + .check_oauth2_account_uuid_valid( + uuid, + session_id, + Some(parent_session_id), + iat, + ct, + ) .map_err(|_| admin_error!("Account is not valid")); let Ok(Some(entry)) = valid else { security_info!( ?uuid, - "access token has no account not valid, returning inactive" + "access token account is not valid, returning inactive" ); return Ok(AccessTokenIntrospectResponse::inactive()); }; @@ -1819,12 +1985,79 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let exp = expiry.unix_timestamp(); + let preferred_username = if prefer_short_username { + Some(account.name.clone()) + } else { + Some(account.spn.clone()) + }; + let token_type = Some("access_token".to_string()); Ok(AccessTokenIntrospectResponse { active: true, scope, client_id: Some(client_id.clone()), - username: Some(account.spn), + username: preferred_username, + token_type, + iat: Some(iat), + exp: Some(exp), + nbf: Some(nbf), + sub: Some(uuid.to_string()), + aud: Some(client_id), + iss: None, + jti: None, + }) + } + Oauth2TokenType::ClientAccess { + scopes, + session_id, + uuid, + expiry, + iat, + nbf, + } => { + // Has this token expired? + let odt_ct = OffsetDateTime::UNIX_EPOCH + ct; + if expiry <= odt_ct { + security_info!(?uuid, "access token has expired, returning inactive"); + return Ok(AccessTokenIntrospectResponse::inactive()); + } + + // We can't do the same validity check for the client as we do with an account + let valid = self + .check_oauth2_account_uuid_valid(uuid, session_id, None, iat, ct) + .map_err(|_| admin_error!("Account is not valid")); + + let Ok(Some(entry)) = valid else { + security_info!( + ?uuid, + "access token account is not valid, returning inactive" + ); + return Ok(AccessTokenIntrospectResponse::inactive()); + }; + + let scope = if scopes.is_empty() { + None + } else { + Some(str_join(&scopes)) + }; + + let exp = expiry.unix_timestamp(); + + let token_type = Some("access_token".to_string()); + + let username = if prefer_short_username { + entry + .get_ava_single_iname(Attribute::Name) + .map(|s| s.to_string()) + } else { + entry.get_ava_single_proto_string(Attribute::Spn) + }; + + Ok(AccessTokenIntrospectResponse { + active: true, + scope, + client_id: Some(client_id.clone()), + username, token_type, iat: Some(iat), exp: Some(exp), @@ -1897,7 +2130,13 @@ impl<'a> IdmServerProxyReadTransaction<'a> { // Is the user expired, or the OAuth2 session invalid? let valid = self - .check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct) + .check_oauth2_account_uuid_valid( + uuid, + session_id, + Some(parent_session_id), + iat, + ct, + ) .map_err(|_| admin_error!("Account is not valid")); let Ok(Some(entry)) = valid else { @@ -1942,6 +2181,10 @@ impl<'a> IdmServerProxyReadTransaction<'a> { }) } // https://openid.net/specs/openid-connect-basic-1_0.html#UserInfoErrorResponse + Oauth2TokenType::ClientAccess { .. } => { + warn!("OpenID userinfo introspection of client access tokens is not currently supported."); + Err(Oauth2Error::InvalidToken) + } Oauth2TokenType::Refresh { .. } => Err(Oauth2Error::InvalidToken), } } @@ -2252,6 +2495,30 @@ fn str_join(set: &BTreeSet) -> String { buf } +fn validate_scopes(req_scopes: &BTreeSet) -> Result<(), Oauth2Error> { + let failed_scopes = req_scopes + .iter() + .filter(|&s| !OAUTHSCOPE_RE.is_match(s)) + .cloned() + .collect::>(); + + if !failed_scopes.is_empty() { + let requested_scopes_string = req_scopes + .iter() + .cloned() + .collect::>() + .join(","); + admin_error!( + "Invalid OAuth2 request - requested scopes ({}) but ({}) failed to pass validation rules - all must match the regex {}", + requested_scopes_string, + failed_scopes.join(","), + OAUTHSCOPE_RE.as_str() + ); + return Err(Oauth2Error::InvalidScope); + } + Ok(()) +} + #[cfg(test)] mod tests { use base64::{engine::general_purpose, Engine as _}; @@ -2284,6 +2551,8 @@ mod tests { const UAT_EXPIRE: u64 = 5; const TOKEN_EXPIRE: u64 = 900; + const UUID_TESTGROUP: Uuid = uuid!("a3028223-bf20-47d5-8b65-967b5d2bb3eb"); + macro_rules! create_code_verifier { ($key:expr) => {{ let code_verifier = $key.to_string(); @@ -2333,10 +2602,19 @@ mod tests { ) -> (String, UserAuthToken, Identity, Uuid) { let mut idms_prox_write = idms.proxy_write(ct).await; - let uuid = Uuid::new_v4(); + let rs_uuid = Uuid::new_v4(); - let e: Entry = entry_init!( + let entry_group: Entry = entry_init!( + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup")), + (Attribute::Description, Value::new_utf8s("testgroup")), + (Attribute::Uuid, Value::Uuid(UUID_TESTGROUP)), + (Attribute::Member, Value::Refer(UUID_TESTPERSON_1),) + ); + + let entry_rs: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -2345,11 +2623,8 @@ mod tests { Attribute::Class, EntryClass::OAuth2ResourceServerBasic.to_value() ), - (Attribute::Uuid, Value::Uuid(uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Uuid, Value::Uuid(rs_uuid)), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -2362,7 +2637,7 @@ mod tests { ( Attribute::OAuth2RsScopeMap, Value::new_oauthscopemap( - UUID_SYSTEM_ADMINS, + UUID_TESTGROUP, btreeset![OAUTH2_SCOPE_GROUPS.to_string()] ) .expect("invalid oauthscope") @@ -2396,12 +2671,12 @@ mod tests { Value::new_bool(prefer_short_username) ) ); - let ce = CreateEvent::new_internal(vec![e]); + let ce = CreateEvent::new_internal(vec![entry_rs, entry_group, E_TESTPERSON_1.clone()]); assert!(idms_prox_write.qs_write.create(&ce).is_ok()); let entry = idms_prox_write .qs_write - .internal_search_uuid(uuid) + .internal_search_uuid(rs_uuid) .expect("Failed to retrieve OAuth2 resource entry "); let secret = entry .get_ava_single_secret(Attribute::OAuth2RsBasicSecret) @@ -2410,12 +2685,12 @@ mod tests { // Setup the uat we'll be using - note for these tests they *require* // the parent session to be valid and present! - let session_id = uuid::Uuid::new_v4(); let account = idms_prox_write - .target_to_account(UUID_ADMIN) + .target_to_account(UUID_TESTPERSON_1) .expect("account must exist"); + let uat = account .to_userauthtoken( session_id, @@ -2459,7 +2734,7 @@ mod tests { idms_prox_write .qs_write .internal_modify( - &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_ADMIN))), + &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), &modlist, ) .expect("Failed to modify user"); @@ -2470,7 +2745,7 @@ mod tests { idms_prox_write.commit().expect("failed to commit"); - (secret, uat, ident, uuid) + (secret, uat, ident, rs_uuid) } async fn setup_oauth2_resource_server_public( @@ -2479,10 +2754,19 @@ mod tests { ) -> (UserAuthToken, Identity, Uuid) { let mut idms_prox_write = idms.proxy_write(ct).await; - let uuid = Uuid::new_v4(); + let rs_uuid = Uuid::new_v4(); - let e: Entry = entry_init!( + let entry_group: Entry = entry_init!( + (Attribute::Class, EntryClass::Group.to_value()), + (Attribute::Name, Value::new_iname("testgroup")), + (Attribute::Description, Value::new_utf8s("testgroup")), + (Attribute::Uuid, Value::Uuid(UUID_TESTGROUP)), + (Attribute::Member, Value::Refer(UUID_TESTPERSON_1),) + ); + + let entry_rs: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -2491,11 +2775,8 @@ mod tests { Attribute::Class, EntryClass::OAuth2ResourceServerPublic.to_value() ), - (Attribute::Uuid, Value::Uuid(uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Uuid, Value::Uuid(rs_uuid)), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -2507,7 +2788,7 @@ mod tests { // System admins ( Attribute::OAuth2RsScopeMap, - Value::new_oauthscopemap(UUID_SYSTEM_ADMINS, btreeset!["groups".to_string()]) + Value::new_oauthscopemap(UUID_TESTGROUP, btreeset!["groups".to_string()]) .expect("invalid oauthscope") ), ( @@ -2527,7 +2808,7 @@ mod tests { .expect("invalid oauthscope") ) ); - let ce = CreateEvent::new_internal(vec![e]); + let ce = CreateEvent::new_internal(vec![entry_rs, entry_group, E_TESTPERSON_1.clone()]); assert!(idms_prox_write.qs_write.create(&ce).is_ok()); // Setup the uat we'll be using - note for these tests they *require* @@ -2536,7 +2817,7 @@ mod tests { let session_id = uuid::Uuid::new_v4(); let account = idms_prox_write - .target_to_account(UUID_ADMIN) + .target_to_account(UUID_TESTPERSON_1) .expect("account must exist"); let uat = account .to_userauthtoken( @@ -2581,7 +2862,7 @@ mod tests { idms_prox_write .qs_write .internal_modify( - &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_ADMIN))), + &filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), &modlist, ) .expect("Failed to modify user"); @@ -2592,7 +2873,7 @@ mod tests { idms_prox_write.commit().expect("failed to commit"); - (uat, ident, uuid) + (uat, ident, rs_uuid) } async fn setup_idm_admin(idms: &IdmServer, ct: Duration) -> (UserAuthToken, Identity) { @@ -3233,7 +3514,7 @@ mod tests { assert!(intr_response.active); assert!(intr_response.scope.as_deref() == Some("openid supplement")); assert!(intr_response.client_id.as_deref() == Some("test_resource_server")); - assert!(intr_response.username.as_deref() == Some("admin@example.com")); + assert!(intr_response.username.as_deref() == Some("testperson1@example.com")); assert!(intr_response.token_type.as_deref() == Some("access_token")); assert!(intr_response.iat == Some(ct.as_secs() as i64)); assert!(intr_response.nbf == Some(ct.as_secs() as i64)); @@ -3245,7 +3526,7 @@ mod tests { // Expire the account, should cause introspect to return inactive. let v_expire = Value::new_datetime_epoch(Duration::from_secs(TEST_CURRENT_TIME - 1)); let me_inv_m = ModifyEvent::new_internal_invalid( - filter!(f_eq(Attribute::Name, PartialValue::new_iname("admin"))), + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), ModifyList::new_list(vec![Modify::Present( Attribute::AccountExpire.into(), v_expire, @@ -3486,8 +3767,10 @@ mod tests { .expect("Failed to access internals of the refresh token"); let session_id = match reflected_token { - Oauth2TokenType::Refresh { session_id, .. } => session_id, Oauth2TokenType::Access { session_id, .. } => session_id, + Oauth2TokenType::Refresh { .. } | Oauth2TokenType::ClientAccess { .. } => { + unreachable!() + } }; assert!(idms_prox_write.commit().is_ok()); @@ -3498,7 +3781,7 @@ mod tests { // Check it is now there let entry = idms_prox_write .qs_write - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("failed"); let valid = entry .get_ava_as_oauth2session_map(Attribute::OAuth2Session) @@ -3509,7 +3792,7 @@ mod tests { // Delete the resource server. let de = DeleteEvent::new_internal_invalid(filter!(f_eq( - Attribute::OAuth2RsName, + Attribute::Name, PartialValue::new_iname("test_resource_server") ))); @@ -3520,7 +3803,7 @@ mod tests { // revoked. let entry = idms_prox_write .qs_write - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("failed"); let revoked = entry .get_ava_as_oauth2session_map(Attribute::OAuth2Session) @@ -3953,7 +4236,7 @@ mod tests { == Url::parse("https://idm.example.com/oauth2/openid/test_resource_server") .unwrap() ); - assert!(oidc.sub == OidcSubject::U(UUID_ADMIN)); + assert!(oidc.sub == OidcSubject::U(UUID_TESTPERSON_1)); assert!(oidc.aud == "test_resource_server"); assert!(oidc.iat == iat); assert!(oidc.nbf == Some(iat)); @@ -3967,8 +4250,8 @@ mod tests { assert!(oidc.amr.is_none()); assert!(oidc.azp == Some("test_resource_server".to_string())); assert!(oidc.jti.is_none()); - assert!(oidc.s_claims.name == Some("System Administrator".to_string())); - assert!(oidc.s_claims.preferred_username == Some("admin@example.com".to_string())); + assert!(oidc.s_claims.name == Some("Test Person 1".to_string())); + assert!(oidc.s_claims.preferred_username == Some("testperson1@example.com".to_string())); assert!( oidc.s_claims.scopes == vec![OAUTH2_SCOPE_OPENID.to_string(), "supplement".to_string()] ); @@ -4119,7 +4402,7 @@ mod tests { .expect("Failed to verify oidc"); // Do we have the short username in the token claims? - assert!(oidc.s_claims.preferred_username == Some("admin".to_string())); + assert!(oidc.s_claims.preferred_username == Some("testperson1".to_string())); // Do the id_token details line up to the userinfo? let userinfo = idms_prox_read .oauth2_openid_userinfo("test_resource_server", &access_token, ct) @@ -4364,7 +4647,7 @@ mod tests { .verify_exp(iat) .expect("Failed to verify oidc"); - assert!(oidc.sub == OidcSubject::U(UUID_ADMIN)); + assert!(oidc.sub == OidcSubject::U(UUID_TESTPERSON_1)); assert!(idms_prox_write.commit().is_ok()); } @@ -4439,7 +4722,7 @@ mod tests { let me_extend_scopes = ModifyEvent::new_internal_invalid( filter!(f_eq( - Attribute::OAuth2RsName, + Attribute::Name, PartialValue::new_iname("test_resource_server") )), ModifyList::new_list(vec![Modify::Present( @@ -4505,7 +4788,7 @@ mod tests { let me_extend_scopes = ModifyEvent::new_internal_invalid( filter!(f_eq( - Attribute::OAuth2RsName, + Attribute::Name, PartialValue::new_iname("test_resource_server") )), ModifyList::new_list(vec![Modify::Present( @@ -4617,7 +4900,7 @@ mod tests { // Now trigger the delete of the RS let de = DeleteEvent::new_internal_invalid(filter!(f_eq( - Attribute::OAuth2RsName, + Attribute::Name, PartialValue::new_iname("test_resource_server") ))); @@ -4935,7 +5218,7 @@ mod tests { let refresh_exp = match reflected_token { Oauth2TokenType::Refresh { expiry, .. } => expiry.unix_timestamp(), - Oauth2TokenType::Access { .. } => unreachable!(), + Oauth2TokenType::Access { .. } | Oauth2TokenType::ClientAccess { .. } => unreachable!(), }; let token_req: AccessTokenRequest = GrantTypeReq::RefreshToken { @@ -5176,7 +5459,7 @@ mod tests { let entry = idms_prox_write .qs_write - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("failed"); let valid = entry .get_ava_as_oauth2session_map(Attribute::OAuth2Session) @@ -5295,7 +5578,7 @@ mod tests { Attribute::OAuth2RsClaimMap.into(), Value::OauthClaimValue( "custom_a".to_string(), - UUID_SYSTEM_ADMINS, + UUID_TESTGROUP, btreeset!["value_a".to_string()], ), ), @@ -5320,7 +5603,7 @@ mod tests { Attribute::OAuth2RsClaimMap.into(), Value::OauthClaimValue( "custom_b".to_string(), - UUID_SYSTEM_ADMINS, + UUID_TESTGROUP, btreeset!["value_a".to_string()], ), ), @@ -5434,7 +5717,7 @@ mod tests { == Url::parse("https://idm.example.com/oauth2/openid/test_resource_server") .unwrap() ); - assert!(oidc.sub == OidcSubject::U(UUID_ADMIN)); + assert!(oidc.sub == OidcSubject::U(UUID_TESTPERSON_1)); assert!(oidc.aud == "test_resource_server"); assert!(oidc.iat == iat); assert!(oidc.nbf == Some(iat)); @@ -5448,8 +5731,8 @@ mod tests { assert!(oidc.amr.is_none()); assert!(oidc.azp == Some("test_resource_server".to_string())); assert!(oidc.jti.is_none()); - assert!(oidc.s_claims.name == Some("System Administrator".to_string())); - assert!(oidc.s_claims.preferred_username == Some("admin@example.com".to_string())); + assert!(oidc.s_claims.name == Some("Test Person 1".to_string())); + assert!(oidc.s_claims.preferred_username == Some("testperson1@example.com".to_string())); assert!( oidc.s_claims.scopes == vec![OAUTH2_SCOPE_OPENID.to_string(), "supplement".to_string()] ); @@ -5498,7 +5781,7 @@ mod tests { assert!(intr_response.active); assert!(intr_response.scope.as_deref() == Some("openid supplement")); assert!(intr_response.client_id.as_deref() == Some("test_resource_server")); - assert!(intr_response.username.as_deref() == Some("admin@example.com")); + assert!(intr_response.username.as_deref() == Some("testperson1@example.com")); assert!(intr_response.token_type.as_deref() == Some("access_token")); assert!(intr_response.iat == Some(ct.as_secs() as i64)); assert!(intr_response.nbf == Some(ct.as_secs() as i64)); @@ -5596,4 +5879,161 @@ mod tests { assert!(idms_prox_write.commit().is_ok()); } + + #[idm_test] + async fn test_idm_oauth2_basic_client_credentials_grant_valid( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, _uat, _ident, _) = + setup_oauth2_resource_server_basic(idms, ct, true, false, false).await; + let client_authz = + Some(general_purpose::STANDARD.encode(format!("test_resource_server:{secret}"))); + + // scope: Some(btreeset!["invalid_scope".to_string()]), + let mut idms_prox_write = idms.proxy_write(ct).await; + + let token_req = AccessTokenRequest { + grant_type: GrantTypeReq::ClientCredentials { scope: None }, + client_id: Some("test_resource_server".to_string()), + client_secret: Some(secret), + }; + + let oauth2_token = idms_prox_write + .check_oauth2_token_exchange(None, &token_req, ct) + .expect("Failed to perform OAuth2 token exchange"); + + assert!(idms_prox_write.commit().is_ok()); + + // 🎉 We got a token! In the future we can then check introspection from this point. + assert!(oauth2_token.token_type == "Bearer"); + + // Check Oauth2 Token Introspection + let mut idms_prox_read = idms.proxy_read().await; + + let intr_request = AccessTokenIntrospectRequest { + token: oauth2_token.access_token.clone(), + token_type_hint: None, + }; + let intr_response = idms_prox_read + .check_oauth2_token_introspect(client_authz.as_deref().unwrap(), &intr_request, ct) + .expect("Failed to inspect token"); + + eprintln!("👉 {intr_response:?}"); + assert!(intr_response.active); + assert_eq!(intr_response.scope.as_deref(), Some("supplement")); + assert_eq!( + intr_response.client_id.as_deref(), + Some("test_resource_server") + ); + assert_eq!( + intr_response.username.as_deref(), + Some("test_resource_server@example.com") + ); + assert_eq!(intr_response.token_type.as_deref(), Some("access_token")); + assert_eq!(intr_response.iat, Some(ct.as_secs() as i64)); + assert_eq!(intr_response.nbf, Some(ct.as_secs() as i64)); + + drop(idms_prox_read); + + // Assert we can revoke. + let mut idms_prox_write = idms.proxy_write(ct).await; + let revoke_request = TokenRevokeRequest { + token: oauth2_token.access_token.clone(), + token_type_hint: None, + }; + assert!(idms_prox_write + .oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct,) + .is_ok()); + assert!(idms_prox_write.commit().is_ok()); + + // Now must be invalid. + let ct = ct + GRACE_WINDOW; + let mut idms_prox_read = idms.proxy_read().await; + + let intr_request = AccessTokenIntrospectRequest { + token: oauth2_token.access_token.clone(), + token_type_hint: None, + }; + + let intr_response = idms_prox_read + .check_oauth2_token_introspect(client_authz.as_deref().unwrap(), &intr_request, ct) + .expect("Failed to inspect token"); + assert!(!intr_response.active); + + drop(idms_prox_read); + } + + #[idm_test] + async fn test_idm_oauth2_basic_client_credentials_grant_invalid( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let (secret, _uat, _ident, _) = + setup_oauth2_resource_server_basic(idms, ct, true, false, false).await; + + let mut idms_prox_write = idms.proxy_write(ct).await; + + // Public Client + let token_req = AccessTokenRequest { + grant_type: GrantTypeReq::ClientCredentials { scope: None }, + client_id: Some("test_resource_server".to_string()), + client_secret: None, + }; + + assert_eq!( + idms_prox_write + .check_oauth2_token_exchange(None, &token_req, ct) + .unwrap_err(), + Oauth2Error::AuthenticationRequired + ); + + // Incorrect Password + let token_req = AccessTokenRequest { + grant_type: GrantTypeReq::ClientCredentials { scope: None }, + client_id: Some("test_resource_server".to_string()), + client_secret: Some("wrong password".to_string()), + }; + + assert_eq!( + idms_prox_write + .check_oauth2_token_exchange(None, &token_req, ct) + .unwrap_err(), + Oauth2Error::AuthenticationRequired + ); + + // Invalid scope + let scope = Some(btreeset!["💅".to_string()]); + let token_req = AccessTokenRequest { + grant_type: GrantTypeReq::ClientCredentials { scope }, + client_id: Some("test_resource_server".to_string()), + client_secret: Some(secret.clone()), + }; + + assert_eq!( + idms_prox_write + .check_oauth2_token_exchange(None, &token_req, ct) + .unwrap_err(), + Oauth2Error::InvalidScope + ); + + // Scopes we aren't a member-of + let scope = Some(btreeset!["invalid_scope".to_string()]); + let token_req = AccessTokenRequest { + grant_type: GrantTypeReq::ClientCredentials { scope }, + client_id: Some("test_resource_server".to_string()), + client_secret: Some(secret.clone()), + }; + + assert_eq!( + idms_prox_write + .check_oauth2_token_exchange(None, &token_req, ct) + .unwrap_err(), + Oauth2Error::AccessDenied + ); + + assert!(idms_prox_write.commit().is_ok()); + } } diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 90443cc80..04a3d654c 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -628,7 +628,7 @@ pub trait IdmServerTransaction<'a> { &mut self, uuid: Uuid, session_id: Uuid, - parent_session_id: Uuid, + parent_session_id: Option, iat: i64, ct: Duration, ) -> Result>>, OperationError> { @@ -661,9 +661,6 @@ pub trait IdmServerTransaction<'a> { let oauth2_session = entry .get_ava_as_oauth2session_map(Attribute::OAuth2Session) .and_then(|sessions| sessions.get(&session_id)); - let uat_session = entry - .get_ava_as_session_map(Attribute::UserAuthTokenSession) - .and_then(|sessions| sessions.get(&parent_session_id)); if let Some(oauth2_session) = oauth2_session { // We have the oauth2 session, lets check it. @@ -674,24 +671,35 @@ pub trait IdmServerTransaction<'a> { return Ok(None); } - if let Some(uat_session) = uat_session { - let parent_session_valid = !matches!(uat_session.state, SessionState::RevokedAt(_)); - if parent_session_valid { - security_info!("A valid parent and oauth2 session value exists for this token"); - } else { + // Do we have a parent session? If yes, we need to enforce it's presence. + if let Some(parent_session_id) = parent_session_id { + let uat_session = entry + .get_ava_as_session_map(Attribute::UserAuthTokenSession) + .and_then(|sessions| sessions.get(&parent_session_id)); + + if let Some(uat_session) = uat_session { + let parent_session_valid = + !matches!(uat_session.state, SessionState::RevokedAt(_)); + if parent_session_valid { + security_info!( + "A valid parent and oauth2 session value exists for this token" + ); + } else { + security_info!( + "The parent oauth2 session associated to this token is revoked." + ); + return Ok(None); + } + } else if grace_valid { security_info!( - "The parent oauth2 session associated to this token is revoked." + "The token grace window is in effect. Assuming parent session valid." ); + } else { + security_info!("The token grace window has passed and no entry parent sessions exist. Assuming invalid."); return Ok(None); } - } else if grace_valid { - security_info!( - "The token grace window is in effect. Assuming parent session valid." - ); - } else { - security_info!("The token grace window has passed and no entry parent sessions exist. Assuming invalid."); - return Ok(None); } + // If we don't have a parent session id, we are good to proceed. } else if grace_valid { security_info!("The token grace window is in effect. Assuming valid."); } else { @@ -2332,16 +2340,24 @@ mod tests { } } - async fn init_admin_w_password(idms: &IdmServer, pw: &str) -> Result { + async fn init_testperson_w_password( + idms: &IdmServer, + pw: &str, + ) -> Result { let p = CryptoPolicy::minimum(); let cred = Credential::new_password_only(&p, pw)?; let cred_id = cred.uuid; let v_cred = Value::new_credential("primary", cred); let mut idms_write = idms.proxy_write(duration_from_epoch_now()).await; + idms_write + .qs_write + .internal_create(vec![E_TESTPERSON_1.clone()]) + .expect("Failed to create test person"); + // now modify and provide a primary credential. let me_inv_m = ModifyEvent::new_internal_invalid( - filter!(f_eq(Attribute::Name, PartialValue::new_iname("admin"))), + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), ModifyList::new_list(vec![Modify::Present( Attribute::PrimaryCredential.into(), v_cred, @@ -2353,7 +2369,7 @@ mod tests { idms_write.commit().map(|()| cred_id) } - async fn init_admin_authsession_sid(idms: &IdmServer, ct: Duration, name: &str) -> Uuid { + async fn init_authsession_sid(idms: &IdmServer, ct: Duration, name: &str) -> Uuid { let mut idms_auth = idms.auth().await; let admin_init = AuthEvent::named_init(name); @@ -2387,9 +2403,9 @@ mod tests { sessionid } - async fn check_admin_password(idms: &IdmServer, pw: &str) -> String { + async fn check_testperson_password(idms: &IdmServer, pw: &str) -> String { let sid = - init_admin_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "admin").await; + init_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "testperson1").await; let mut idms_auth = idms.auth().await; let anon_step = AuthEvent::cred_step_password(sid, pw); @@ -2436,10 +2452,10 @@ mod tests { #[idm_test] async fn test_idm_simple_password_auth(idms: &IdmServer, idms_delayed: &mut IdmServerDelayed) { - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - check_admin_password(idms, TEST_PASSWORD).await; + check_testperson_password(idms, TEST_PASSWORD).await; // Clear our the session record let da = idms_delayed.try_recv().expect("invalid"); @@ -2452,14 +2468,14 @@ mod tests { idms: &IdmServer, idms_delayed: &mut IdmServerDelayed, ) { - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - let sid = init_admin_authsession_sid( + let sid = init_authsession_sid( idms, Duration::from_secs(TEST_CURRENT_TIME), - "admin@example.com", + "testperson1@example.com", ) .await; @@ -2513,11 +2529,11 @@ mod tests { _idms_delayed: &IdmServerDelayed, idms_audit: &mut IdmServerAudit, ) { - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); let sid = - init_admin_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "admin").await; + init_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "testperson1").await; let mut idms_auth = idms.auth().await; let anon_step = AuthEvent::cred_step_password(sid, TEST_PASSWORD_INC); @@ -2588,7 +2604,13 @@ mod tests { #[idm_test] async fn test_idm_regenerate_radius_secret(idms: &IdmServer, _idms_delayed: &IdmServerDelayed) { let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await; - let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_ADMIN); + + idms_prox_write + .qs_write + .internal_create(vec![E_TESTPERSON_1.clone()]) + .expect("unable to create test person"); + + let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_TESTPERSON_1); // Generates a new credential when none exists let r1 = idms_prox_write @@ -2604,19 +2626,25 @@ mod tests { #[idm_test] async fn test_idm_radiusauthtoken(idms: &IdmServer, _idms_delayed: &IdmServerDelayed) { let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await; - let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_ADMIN); + + idms_prox_write + .qs_write + .internal_create(vec![E_TESTPERSON_1.clone()]) + .expect("unable to create test person"); + + let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_TESTPERSON_1); let r1 = idms_prox_write .regenerate_radius_secret(&rrse) .expect("Failed to reset radius credential 1"); idms_prox_write.commit().expect("failed to commit"); let mut idms_prox_read = idms.proxy_read().await; - let admin_entry = idms_prox_read + let person_entry = idms_prox_read .qs_read - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("Can't access admin entry."); - let rate = RadiusAuthTokenEvent::new_impersonate(admin_entry, UUID_ADMIN); + let rate = RadiusAuthTokenEvent::new_impersonate(person_entry, UUID_TESTPERSON_1); let tok_r = idms_prox_read .get_radiusauthtoken(&rate, duration_from_epoch_now()) .expect("Failed to generate radius auth token"); @@ -2780,9 +2808,15 @@ mod tests { { let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await; // now modify and provide a primary credential. + + idms_prox_write + .qs_write + .internal_create(vec![E_TESTPERSON_1.clone()]) + .expect("Failed to create test person"); + let me_inv_m = ModifyEvent::new_internal_invalid( - filter!(f_eq(Attribute::Name, PartialValue::new_iname("admin"))), + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), ModifyList::new_list(vec![Modify::Present( Attribute::PasswordImport.into(), Value::from("{SSHA512}JwrSUHkI7FTAfHRVR6KoFlSN0E3dmaQWARjZ+/UsShYlENOqDtFVU77HJLLrY2MuSp0jve52+pwtdVl2QUAHukQ0XUf5LDtM") @@ -2796,18 +2830,18 @@ mod tests { idms_delayed.check_is_empty_or_panic(); let mut idms_prox_read = idms.proxy_read().await; - let admin_entry = idms_prox_read + let person_entry = idms_prox_read .qs_read - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("Can't access admin entry."); - let cred_before = admin_entry + let cred_before = person_entry .get_ava_single_credential(Attribute::PrimaryCredential) .expect("No credential present") .clone(); drop(idms_prox_read); // Do an auth, this will trigger the action to send. - check_admin_password(idms, "password").await; + check_testperson_password(idms, "password").await; // ⚠️ We have to be careful here. Between these two actions, it's possible // that on the pw upgrade that the credential uuid changes. This immediately @@ -2827,11 +2861,11 @@ mod tests { assert!(Ok(true) == r); let mut idms_prox_read = idms.proxy_read().await; - let admin_entry = idms_prox_read + let person_entry = idms_prox_read .qs_read - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("Can't access admin entry."); - let cred_after = admin_entry + let cred_after = person_entry .get_ava_single_credential(Attribute::PrimaryCredential) .expect("No credential present") .clone(); @@ -2840,7 +2874,7 @@ mod tests { assert_eq!(cred_before.uuid, cred_after.uuid); // Check the admin pw still matches - check_admin_password(idms, "password").await; + check_testperson_password(idms, "password").await; // Clear the next auth session record let da = idms_delayed.try_recv().expect("invalid"); assert!(matches!(da, DelayedAction::AuthSessionRecord(_))); @@ -2909,7 +2943,7 @@ mod tests { const TEST_EXPIRE_TIME: u64 = TEST_CURRENT_TIME + 120; const TEST_AFTER_EXPIRY: u64 = TEST_CURRENT_TIME + 240; - async fn set_admin_valid_time(idms: &IdmServer) { + async fn set_testperson_valid_time(idms: &IdmServer) { let mut idms_write = idms.proxy_write(duration_from_epoch_now()).await; let v_valid_from = Value::new_datetime_epoch(Duration::from_secs(TEST_VALID_FROM_TIME)); @@ -2917,7 +2951,7 @@ mod tests { // now modify and provide a primary credential. let me_inv_m = ModifyEvent::new_internal_invalid( - filter!(f_eq(Attribute::Name, PartialValue::new_iname("admin"))), + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), ModifyList::new_list(vec![ Modify::Present(Attribute::AccountExpire.into(), v_expire), Modify::Present(Attribute::AccountValidFrom.into(), v_valid_from), @@ -2936,12 +2970,12 @@ mod tests { ) { // Any account that is not yet valrid / expired can't auth. - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); // Set the valid bounds high/low // TEST_VALID_FROM_TIME/TEST_EXPIRE_TIME - set_admin_valid_time(idms).await; + set_testperson_valid_time(idms).await; let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME); let time_high = Duration::from_secs(TEST_AFTER_EXPIRY); @@ -2996,10 +3030,10 @@ mod tests { _idms_delayed: &mut IdmServerDelayed, ) { // Any account that is expired can't unix auth. - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - set_admin_valid_time(idms).await; + set_testperson_valid_time(idms).await; let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME); let time_high = Duration::from_secs(TEST_AFTER_EXPIRY); @@ -3007,7 +3041,7 @@ mod tests { // make the admin a valid posix account let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await; let me_posix = ModifyEvent::new_internal_invalid( - filter!(f_eq(Attribute::Name, PartialValue::new_iname("admin"))), + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), ModifyList::new_list(vec![ Modify::Present(Attribute::Class.into(), EntryClass::PosixAccount.into()), Modify::Present(Attribute::GidNumber.into(), Value::new_uint32(2001)), @@ -3015,14 +3049,14 @@ mod tests { ); assert!(idms_prox_write.qs_write.modify(&me_posix).is_ok()); - let pce = UnixPasswordChangeEvent::new_internal(UUID_ADMIN, TEST_PASSWORD); + let pce = UnixPasswordChangeEvent::new_internal(UUID_TESTPERSON_1, TEST_PASSWORD); assert!(idms_prox_write.set_unix_account_password(&pce).is_ok()); assert!(idms_prox_write.commit().is_ok()); // Now check auth when the time is too high or too low. let mut idms_auth = idms.auth().await; - let uuae_good = UnixUserAuthEvent::new_internal(UUID_ADMIN, TEST_PASSWORD); + let uuae_good = UnixUserAuthEvent::new_internal(UUID_TESTPERSON_1, TEST_PASSWORD); let a1 = idms_auth.auth_unix(&uuae_good, time_low).await; // Should this actually send an error with the details? Or just silently act as @@ -3041,20 +3075,20 @@ mod tests { idms_auth.commit().expect("Must not fail"); // Also check the generated unix tokens are invalid. let mut idms_prox_read = idms.proxy_read().await; - let uute = UnixUserTokenEvent::new_internal(UUID_ADMIN); + let uute = UnixUserTokenEvent::new_internal(UUID_TESTPERSON_1); let tok_r = idms_prox_read .get_unixusertoken(&uute, time_low) .expect("Failed to generate unix user token"); - assert!(tok_r.name == "admin"); + assert!(tok_r.name == "testperson1"); assert!(!tok_r.valid); let tok_r = idms_prox_read .get_unixusertoken(&uute, time_high) .expect("Failed to generate unix user token"); - assert!(tok_r.name == "admin"); + assert!(tok_r.name == "testperson1"); assert!(!tok_r.valid); } @@ -3065,16 +3099,16 @@ mod tests { ) { // Any account not valid/expiry should not return // a radius packet. - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - set_admin_valid_time(idms).await; + set_testperson_valid_time(idms).await; let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME); let time_high = Duration::from_secs(TEST_AFTER_EXPIRY); let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await; - let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_ADMIN); + let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_TESTPERSON_1); let _r1 = idms_prox_write .regenerate_radius_secret(&rrse) .expect("Failed to reset radius credential 1"); @@ -3110,13 +3144,13 @@ mod tests { idms_delayed: &mut IdmServerDelayed, idms_audit: &mut IdmServerAudit, ) { - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); // Auth invalid, no softlock present. let sid = - init_admin_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "admin").await; + init_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "testperson1").await; let mut idms_auth = idms.auth().await; let anon_step = AuthEvent::cred_step_password(sid, TEST_PASSWORD_INC); @@ -3163,7 +3197,7 @@ mod tests { // aka Auth valid immediate, (ct < exp), autofail // aka Auth invalid immediate, (ct < exp), autofail let mut idms_auth = idms.auth().await; - let admin_init = AuthEvent::named_init("admin"); + let admin_init = AuthEvent::named_init("testperson1"); let r1 = idms_auth .auth( @@ -3208,9 +3242,12 @@ mod tests { // Tested in the softlock state machine. // Auth valid once softlock pass, valid. Count remains. - let sid = - init_admin_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME + 2), "admin") - .await; + let sid = init_authsession_sid( + idms, + Duration::from_secs(TEST_CURRENT_TIME + 2), + "testperson1", + ) + .await; let mut idms_auth = idms.auth().await; let anon_step = AuthEvent::cred_step_password(sid, TEST_PASSWORD); @@ -3269,17 +3306,17 @@ mod tests { _idms_delayed: &mut IdmServerDelayed, idms_audit: &mut IdmServerAudit, ) { - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); // Start an *early* auth session. let sid_early = - init_admin_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "admin").await; + init_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "testperson1").await; // Start a second auth session let sid_later = - init_admin_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "admin").await; + init_authsession_sid(idms, Duration::from_secs(TEST_CURRENT_TIME), "testperson1").await; // Get the detail wrong in sid_later. let mut idms_auth = idms.auth().await; let anon_step = AuthEvent::cred_step_password(sid_later, TEST_PASSWORD_INC); @@ -3364,13 +3401,13 @@ mod tests { idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed, ) { - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); // make the admin a valid posix account let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now()).await; let me_posix = ModifyEvent::new_internal_invalid( - filter!(f_eq(Attribute::Name, PartialValue::new_iname("admin"))), + filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_TESTPERSON_1))), ModifyList::new_list(vec![ Modify::Present(Attribute::Class.into(), EntryClass::PosixAccount.into()), Modify::Present(Attribute::GidNumber.into(), Value::new_uint32(2001)), @@ -3378,13 +3415,13 @@ mod tests { ); assert!(idms_prox_write.qs_write.modify(&me_posix).is_ok()); - let pce = UnixPasswordChangeEvent::new_internal(UUID_ADMIN, TEST_PASSWORD); + let pce = UnixPasswordChangeEvent::new_internal(UUID_TESTPERSON_1, TEST_PASSWORD); assert!(idms_prox_write.set_unix_account_password(&pce).is_ok()); assert!(idms_prox_write.commit().is_ok()); let mut idms_auth = idms.auth().await; - let uuae_good = UnixUserAuthEvent::new_internal(UUID_ADMIN, TEST_PASSWORD); - let uuae_bad = UnixUserAuthEvent::new_internal(UUID_ADMIN, TEST_PASSWORD_INC); + let uuae_good = UnixUserAuthEvent::new_internal(UUID_TESTPERSON_1, TEST_PASSWORD); + let uuae_bad = UnixUserAuthEvent::new_internal(UUID_TESTPERSON_1, TEST_PASSWORD_INC); let a2 = idms_auth .auth_unix(&uuae_bad, Duration::from_secs(TEST_CURRENT_TIME)) @@ -3420,10 +3457,10 @@ mod tests { let ct = Duration::from_secs(TEST_CURRENT_TIME); let expiry = ct + Duration::from_secs((DEFAULT_AUTH_SESSION_EXPIRY + 1).into()); // Do an authenticate - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - let token = check_admin_password(idms, TEST_PASSWORD).await; + let token = check_testperson_password(idms, TEST_PASSWORD).await; // Clear out the queued session record let da = idms_delayed.try_recv().expect("invalid"); @@ -3460,7 +3497,7 @@ mod tests { let session_b = Uuid::new_v4(); // We need to put the credential on the admin. - let cred_id = init_admin_w_password(idms, TEST_PASSWORD) + let cred_id = init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); @@ -3468,14 +3505,14 @@ mod tests { let mut idms_prox_read = idms.proxy_read().await; let admin = idms_prox_read .qs_read - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("failed"); let sessions = admin.get_ava_as_session_map(Attribute::UserAuthTokenSession); assert!(sessions.is_none()); drop(idms_prox_read); let da = DelayedAction::AuthSessionRecord(AuthSessionRecord { - target_uuid: UUID_ADMIN, + target_uuid: UUID_TESTPERSON_1, session_id: session_a, cred_id, label: "Test Session A".to_string(), @@ -3492,7 +3529,7 @@ mod tests { let mut idms_prox_read = idms.proxy_read().await; let admin = idms_prox_read .qs_read - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("failed"); let sessions = admin .get_ava_as_session_map(Attribute::UserAuthTokenSession) @@ -3506,7 +3543,7 @@ mod tests { // When we re-auth, this is what triggers the session revoke via the delayed action. let da = DelayedAction::AuthSessionRecord(AuthSessionRecord { - target_uuid: UUID_ADMIN, + target_uuid: UUID_TESTPERSON_1, session_id: session_b, cred_id, label: "Test Session B".to_string(), @@ -3522,7 +3559,7 @@ mod tests { let mut idms_prox_read = idms.proxy_read().await; let admin = idms_prox_read .qs_read - .internal_search_uuid(UUID_ADMIN) + .internal_search_uuid(UUID_TESTPERSON_1) .expect("failed"); let sessions = admin .get_ava_as_session_map(Attribute::UserAuthTokenSession) @@ -3556,10 +3593,10 @@ mod tests { assert!(post_grace < expiry); // Do an authenticate - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - let token = check_admin_password(idms, TEST_PASSWORD).await; + let token = check_testperson_password(idms, TEST_PASSWORD).await; // Process the session info. let da = idms_delayed.try_recv().expect("invalid"); @@ -3922,10 +3959,10 @@ mod tests { ) { let ct = Duration::from_secs(TEST_CURRENT_TIME); - init_admin_w_password(idms, TEST_PASSWORD) + init_testperson_w_password(idms, TEST_PASSWORD) .await .expect("Failed to setup admin account"); - let token = check_admin_password(idms, TEST_PASSWORD).await; + let token = check_testperson_password(idms, TEST_PASSWORD).await; // Clear the session record let da = idms_delayed.try_recv().expect("invalid"); @@ -3958,7 +3995,7 @@ mod tests { assert!(idms_prox_write.qs_write.modify(&me_reset_tokens).is_ok()); assert!(idms_prox_write.commit().is_ok()); // Check the old token is invalid, due to reload. - let new_token = check_admin_password(idms, TEST_PASSWORD).await; + let new_token = check_testperson_password(idms, TEST_PASSWORD).await; // Clear the session record let da = idms_delayed.try_recv().expect("invalid"); diff --git a/server/lib/src/plugins/attrunique.rs b/server/lib/src/plugins/attrunique.rs index 1979015b2..0b6893188 100644 --- a/server/lib/src/plugins/attrunique.rs +++ b/server/lib/src/plugins/attrunique.rs @@ -500,6 +500,7 @@ mod tests { fn test_pre_create_name_unique() { let e: Entry = entry_init!( (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), (Attribute::Name, Value::new_iname("testperson")), (Attribute::Description, Value::new_utf8s("testperson")), (Attribute::DisplayName, Value::new_utf8s("testperson")) @@ -524,6 +525,7 @@ mod tests { fn test_pre_create_name_unique_2() { let e: Entry = entry_init!( (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), (Attribute::Name, Value::new_iname("testperson")), (Attribute::Description, Value::new_utf8s("testperson")), (Attribute::DisplayName, Value::new_utf8s("testperson")) diff --git a/server/lib/src/plugins/base.rs b/server/lib/src/plugins/base.rs index ab6c24059..2de84247e 100644 --- a/server/lib/src/plugins/base.rs +++ b/server/lib/src/plugins/base.rs @@ -333,7 +333,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person"], + "class": ["person", "account"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"] @@ -369,7 +369,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person"], + "class": ["person", "account"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"], @@ -399,7 +399,7 @@ mod tests { let mut e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person"], + "class": ["person", "account"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"], @@ -432,7 +432,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person"], + "class": ["person", "account"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"], @@ -506,7 +506,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person"], + "class": ["account", "person"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"], diff --git a/server/lib/src/plugins/domain.rs b/server/lib/src/plugins/domain.rs index 5dd480c99..ab7d832ab 100644 --- a/server/lib/src/plugins/domain.rs +++ b/server/lib/src/plugins/domain.rs @@ -104,7 +104,7 @@ impl Domain { // Setup the minimum functional level if one is not set already. if !e.attribute_pres(Attribute::Version) { - let n = Value::Uint32(DOMAIN_MIN_LEVEL); + let n = Value::Uint32(DOMAIN_LEVEL_0); e.set_ava(Attribute::Version, once(n)); warn!("plugin_domain: Applying domain version transform"); } else { diff --git a/server/lib/src/plugins/dyngroup.rs b/server/lib/src/plugins/dyngroup.rs index 8c9c19101..33df00c45 100644 --- a/server/lib/src/plugins/dyngroup.rs +++ b/server/lib/src/plugins/dyngroup.rs @@ -244,6 +244,7 @@ impl DynGroup { pre_cand: &[Arc>], cand: &[Entry], _ident: &Identity, + force_cand_updates: bool, ) -> Result, OperationError> { let mut affected_uuids = Vec::with_capacity(cand.len()); @@ -290,8 +291,7 @@ impl DynGroup { // If we modified anything else, check if a dyngroup is affected by it's change // if it was a member. - - trace!(?dyn_groups.insts); + trace!(?force_cand_updates, ?dyn_groups.insts); for (dg_uuid, dg_filter) in dyn_groups.insts.iter() { let dg_filter_valid = dg_filter @@ -308,16 +308,26 @@ impl DynGroup { let pre_t = pre.entry_match_no_index(&dg_filter_valid); let post_t = post.entry_match_no_index(&dg_filter_valid); - if pre_t && !post_t { - Some(Err(post.get_uuid())) - } else if !pre_t && post_t { + trace!(?post_t, ?force_cand_updates, ?pre_t); + + // There are some cases where rather than the optimisation to skip + // asserting membership, we need to always assert that membership. Generally + // this occurs in replication where if a candidate was conflicted it can + // trigger a membership delete, but we need to ensure it's still re-added. + if post_t && (force_cand_updates || !pre_t) { + // The entry was added Some(Ok(post.get_uuid())) + } else if pre_t && !post_t { + // The entry was deleted + Some(Err(post.get_uuid())) } else { None } }) .collect(); + trace!(?matches); + if !matches.is_empty() { let filt = filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(*dg_uuid))); let mut work_set = qs.internal_search_writeable(&filt)?; @@ -348,6 +358,8 @@ impl DynGroup { })?; } + trace!(?affected_uuids); + Ok(affected_uuids) } diff --git a/server/lib/src/plugins/jwskeygen.rs b/server/lib/src/plugins/jwskeygen.rs index 6eb1a7535..0dcbad5d1 100644 --- a/server/lib/src/plugins/jwskeygen.rs +++ b/server/lib/src/plugins/jwskeygen.rs @@ -114,6 +114,7 @@ mod tests { let uuid = Uuid::new_v4(); let e: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -127,10 +128,7 @@ mod tests { Attribute::DisplayName, Value::new_utf8s("test_resource_server") ), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::OAuth2RsOrigin, Value::new_url_s("https://demo.example.com").unwrap() @@ -168,6 +166,7 @@ mod tests { let e: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -177,10 +176,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") diff --git a/server/lib/src/plugins/memberof.rs b/server/lib/src/plugins/memberof.rs index b1f1a9182..67f416131 100644 --- a/server/lib/src/plugins/memberof.rs +++ b/server/lib/src/plugins/memberof.rs @@ -235,12 +235,26 @@ impl Plugin for MemberOf { qs: &mut QueryServerWriteTransaction, pre_cand: &[Arc], cand: &[EntrySealedCommitted], - _conflict_uuids: &BTreeSet, + conflict_uuids: &BTreeSet, ) -> Result<(), OperationError> { + // If a uuid was in a conflict state, it will be present in the cand/pre_cand set, + // but it *may not* trigger dyn groups as the conflict before and after may satisfy + // the filter as it exists. + // + // In these cases we need to force dynmembers to be reloaded if any conflict occurs + // to ensure that all our memberships are accurate. + let force_dyngroup_cand_update = !conflict_uuids.is_empty(); + // IMPORTANT - we need this for now so that dyngroup doesn't error on us, since // repl is internal and dyngroup has a safety check to prevent external triggers. let ident_internal = Identity::from_internal(); - Self::post_modify_inner(qs, pre_cand, cand, &ident_internal) + Self::post_modify_inner( + qs, + pre_cand, + cand, + &ident_internal, + force_dyngroup_cand_update, + ) } #[instrument(level = "debug", name = "memberof_post_modify", skip_all)] @@ -250,7 +264,7 @@ impl Plugin for MemberOf { cand: &[Entry], me: &ModifyEvent, ) -> Result<(), OperationError> { - Self::post_modify_inner(qs, pre_cand, cand, &me.ident) + Self::post_modify_inner(qs, pre_cand, cand, &me.ident, false) } #[instrument(level = "debug", name = "memberof_post_batch_modify", skip_all)] @@ -260,7 +274,29 @@ impl Plugin for MemberOf { cand: &[Entry], me: &BatchModifyEvent, ) -> Result<(), OperationError> { - Self::post_modify_inner(qs, pre_cand, cand, &me.ident) + Self::post_modify_inner(qs, pre_cand, cand, &me.ident, false) + } + + #[instrument(level = "debug", name = "memberof_pre_delete", skip_all)] + fn pre_delete( + _qs: &mut QueryServerWriteTransaction, + cand: &mut Vec, + _de: &DeleteEvent, + ) -> Result<(), OperationError> { + // Ensure that when an entry is deleted, that we remove its memberof values, + // and convert direct memberof to recycled direct memberof. + + for entry in cand.iter_mut() { + if let Some(direct_mo_vs) = entry.pop_ava(Attribute::DirectMemberOf) { + entry.set_ava_set(Attribute::RecycledDirectMemberOf, direct_mo_vs); + } else { + // Ensure it's empty + entry.purge_ava(Attribute::RecycledDirectMemberOf); + } + entry.purge_ava(Attribute::MemberOf); + } + + Ok(()) } #[instrument(level = "debug", name = "memberof_post_delete", skip_all)] @@ -368,11 +404,14 @@ impl Plugin for MemberOf { (None, None) => { // Ok } - _ => { + (entry_dmo, d_groups) => { admin_error!( "MemberOfInvalid directmemberof set and DMO search set differ in size: {}", e.get_uuid() ); + trace!(?e); + trace!(?entry_dmo); + trace!(?d_groups); r.push(Err(ConsistencyError::MemberOfInvalid(e.get_id()))); } } @@ -429,8 +468,15 @@ impl MemberOf { pre_cand: &[Arc], cand: &[EntrySealedCommitted], ident: &Identity, + force_dyngroup_cand_update: bool, ) -> Result<(), OperationError> { - let dyngroup_change = super::dyngroup::DynGroup::post_modify(qs, pre_cand, cand, ident)?; + let dyngroup_change = super::dyngroup::DynGroup::post_modify( + qs, + pre_cand, + cand, + ident, + force_dyngroup_cand_update, + )?; // TODO: Limit this to when it's a class, member, mo, dmo change instead. let group_affect = cand diff --git a/server/lib/src/plugins/mod.rs b/server/lib/src/plugins/mod.rs index cb17dd276..15e565ed7 100644 --- a/server/lib/src/plugins/mod.rs +++ b/server/lib/src/plugins/mod.rs @@ -338,7 +338,8 @@ impl Plugins { cand: &mut Vec>, de: &DeleteEvent, ) -> Result<(), OperationError> { - protected::Protected::pre_delete(qs, cand, de) + protected::Protected::pre_delete(qs, cand, de)?; + memberof::MemberOf::pre_delete(qs, cand, de) } #[instrument(level = "debug", name = "plugins::run_post_delete", skip_all)] diff --git a/server/lib/src/plugins/protected.rs b/server/lib/src/plugins/protected.rs index 3205d12b2..ba58fbef3 100644 --- a/server/lib/src/plugins/protected.rs +++ b/server/lib/src/plugins/protected.rs @@ -397,6 +397,7 @@ mod tests { Attribute::PrivateCookieKey.to_value() ), (Attribute::AcpCreateClass, EntryClass::Object.to_value()), + (Attribute::AcpCreateClass, EntryClass::Account.to_value()), (Attribute::AcpCreateClass, EntryClass::Person.to_value()), (Attribute::AcpCreateClass, EntryClass::System.to_value()), (Attribute::AcpCreateClass, EntryClass::DomainInfo.to_value()), @@ -438,7 +439,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person", "system"], + "class": ["account", "person", "system"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"] @@ -464,7 +465,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person", "system"], + "class": ["account", "person", "system"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"] @@ -526,7 +527,7 @@ mod tests { let e: Entry = Entry::unsafe_from_entry_str( r#"{ "attrs": { - "class": ["person", "system"], + "class": ["account", "person", "system"], "name": ["testperson"], "description": ["testperson"], "displayname": ["testperson"] diff --git a/server/lib/src/plugins/refint.rs b/server/lib/src/plugins/refint.rs index a87c17f6f..93eca58c3 100644 --- a/server/lib/src/plugins/refint.rs +++ b/server/lib/src/plugins/refint.rs @@ -990,15 +990,13 @@ mod tests { // scope map is also appropriately affected. let ea: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() ), // (Attribute::Class, EntryClass::OAuth2ResourceServerBasic.into()), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -1037,7 +1035,7 @@ mod tests { |qs: &mut QueryServerWriteTransaction| { let cands = qs .internal_search(filter!(f_eq( - Attribute::OAuth2RsName, + Attribute::Name, PartialValue::new_iname("test_resource_server") ))) .expect("Internal search failure"); @@ -1080,15 +1078,13 @@ mod tests { let e2 = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() ), (Attribute::Uuid, Value::Uuid(rs_uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -1129,7 +1125,7 @@ mod tests { Value::Oauth2Session( session_id, Oauth2Session { - parent: parent_id, + parent: Some(parent_id), // Note we set the exp to None so we are not removing based on exp state: SessionState::NeverExpires, issued_at, @@ -1309,6 +1305,7 @@ mod tests { fn test_delete_remove_reference_oauth2_claim_map() { let ea: Entry = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -1317,10 +1314,7 @@ mod tests { Attribute::Class, EntryClass::OAuth2ResourceServerPublic.to_value() ), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -1366,7 +1360,7 @@ mod tests { |qs: &mut QueryServerWriteTransaction| { let cands = qs .internal_search(filter!(f_eq( - Attribute::OAuth2RsName, + Attribute::Name, PartialValue::new_iname("test_resource_server") ))) .expect("Internal search failure"); diff --git a/server/lib/src/plugins/session.rs b/server/lib/src/plugins/session.rs index 16662eeda..7f015408b 100644 --- a/server/lib/src/plugins/session.rs +++ b/server/lib/src/plugins/session.rs @@ -131,12 +131,19 @@ impl SessionConsistency { _ => { // Okay, now check the issued / grace time for parent enforcement. if sessions.map(|session_map| { - if let Some(parent_session) = session_map.get(&session.parent) { - // Only match non-revoked sessions - !matches!(parent_session.state, SessionState::RevokedAt(_)) + if let Some(parent_session_id) = session.parent.as_ref() { + // A parent session id exists - validate it exists in the account. + if let Some(parent_session) = session_map.get(parent_session_id) { + // Only match non-revoked sessions + !matches!(parent_session.state, SessionState::RevokedAt(_)) + } else { + // not found + false + } } else { - // not found - false + // The session specifically has no parent session and so is + // not bounded by it's presence. + true } }).unwrap_or(false) { // The parent exists and is still valid, go ahead @@ -145,7 +152,7 @@ impl SessionConsistency { } else { // Can't find the parent. Are we within grace window if session.issued_at + GRACE_WINDOW <= curtime_odt { - info!(%o2_session_id, parent_id = %session.parent, "Removing orphaned oauth2 session"); + info!(%o2_session_id, parent_id = ?session.parent, "Removing orphaned oauth2 session"); Some(PartialValue::Refer(*o2_session_id)) } else { // Grace window is still in effect @@ -330,6 +337,7 @@ mod tests { let e2 = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -339,10 +347,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(rs_uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -381,7 +386,7 @@ mod tests { Value::Oauth2Session( session_id, Oauth2Session { - parent: parent_id, + parent: Some(parent_id), // Set to the exp window. state, issued_at, @@ -505,6 +510,7 @@ mod tests { let e2 = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -514,10 +520,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(rs_uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -555,7 +558,7 @@ mod tests { Value::Oauth2Session( session_id, Oauth2Session { - parent: parent_id, + parent: Some(parent_id), // Note we set the exp to None so we are not removing based on exp state: SessionState::NeverExpires, issued_at, @@ -673,6 +676,7 @@ mod tests { let e2 = entry_init!( (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), ( Attribute::Class, EntryClass::OAuth2ResourceServer.to_value() @@ -682,10 +686,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(rs_uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -716,7 +717,7 @@ mod tests { let session = Value::Oauth2Session( session_id, Oauth2Session { - parent, + parent: Some(parent), // Note we set the exp to None so we are asserting the removal is due to the lack // of the parent session. state: SessionState::NeverExpires, diff --git a/server/lib/src/plugins/valuedeny.rs b/server/lib/src/plugins/valuedeny.rs index c99b84ad2..7bf9f596d 100644 --- a/server/lib/src/plugins/valuedeny.rs +++ b/server/lib/src/plugins/valuedeny.rs @@ -136,6 +136,7 @@ mod tests { assert!(server_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("tobias")), (Attribute::Uuid, Value::Uuid(t_uuid)), @@ -154,6 +155,7 @@ mod tests { assert!(server_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("newname")), (Attribute::Uuid, Value::Uuid(t_uuid)), @@ -181,6 +183,7 @@ mod tests { assert!(server_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("newname")), (Attribute::Uuid, Value::Uuid(t_uuid)), diff --git a/server/lib/src/repl/consumer.rs b/server/lib/src/repl/consumer.rs index 2ded1d16a..aa09a431b 100644 --- a/server/lib/src/repl/consumer.rs +++ b/server/lib/src/repl/consumer.rs @@ -184,10 +184,18 @@ impl<'a> QueryServerWriteTransaction<'a> { // let (cand, pre_cand): (Vec<_>, Vec<_>) = all_updates_valid .into_iter() + // We previously excluded this to avoid doing unnecesary work on entries that + // were moving to a conflict state, and the survivor was staying "as is" on this + // node. However, this gets messy with dyngroups and memberof, where on a conflict + // the memberships are deleted across the replication boundary. In these cases + // we need dyngroups to see the valid entries, even if they are "identical to before" + // to re-assert all their memberships are valid. + /* .filter(|(cand, _)| { // Exclude anything that is conflicted as a result of the conflict plugins. !conflict_uuids.contains(&cand.get_uuid()) }) + */ .unzip(); // We don't need to process conflict_creates here, since they are all conflicting diff --git a/server/lib/src/repl/entry.rs b/server/lib/src/repl/entry.rs index b5d8adb02..39a6dc780 100644 --- a/server/lib/src/repl/entry.rs +++ b/server/lib/src/repl/entry.rs @@ -215,9 +215,9 @@ impl EntryChangeState { } #[cfg(test)] - pub(crate) fn get_attr_cid(&self, attr: &Attribute) -> Option { + pub(crate) fn get_attr_cid(&self, attr: &Attribute) -> Option<&Cid> { match &self.st { - State::Live { at: _, changes } => changes.get(attr.as_ref()).map(|cid| cid.clone()), + State::Live { at: _, changes } => changes.get(attr.as_ref()), State::Tombstone { at: _ } => None, } } diff --git a/server/lib/src/repl/proto.rs b/server/lib/src/repl/proto.rs index 711cef36e..38f71e7d4 100644 --- a/server/lib/src/repl/proto.rs +++ b/server/lib/src/repl/proto.rs @@ -9,6 +9,7 @@ use crate::schema::{SchemaReadTransaction, SchemaTransaction}; use crate::valueset; use base64urlsafedata::Base64UrlSafeData; use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; use std::collections::{BTreeMap, BTreeSet}; use webauthn_rs::prelude::{ @@ -261,10 +262,11 @@ pub struct ReplOauthClaimMapV1 { pub values: BTreeMap>, } +#[skip_serializing_none] #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct ReplOauth2SessionV1 { pub refer: Uuid, - pub parent: Uuid, + pub parent: Option, pub state: ReplSessionStateV1, // pub expiry: Option, pub issued_at: String, diff --git a/server/lib/src/repl/supplier.rs b/server/lib/src/repl/supplier.rs index 95ea8ea90..1af2bcd97 100644 --- a/server/lib/src/repl/supplier.rs +++ b/server/lib/src/repl/supplier.rs @@ -148,9 +148,13 @@ impl<'a> QueryServerReadTransaction<'a> { } }; - debug!(?ranges, "these ranges will be supplied"); + debug!("these ranges will be supplied"); + debug!(supply_ranges = ?ranges); + debug!(consumer_ranges = ?ctx_ranges); + debug!(supplier_ranges = ?our_ranges); if ranges.is_empty() { + debug!("No Changes Available"); return Ok(ReplIncrementalContext::NoChangesAvailable); } diff --git a/server/lib/src/repl/tests.rs b/server/lib/src/repl/tests.rs index 3a6f5ad0e..7ddcc5616 100644 --- a/server/lib/src/repl/tests.rs +++ b/server/lib/src/repl/tests.rs @@ -250,6 +250,7 @@ async fn test_repl_increment_basic_entry_add(server_a: &QueryServer, server_b: & assert!(server_b_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)), @@ -353,6 +354,7 @@ async fn test_repl_increment_basic_entry_recycle(server_a: &QueryServer, server_ assert!(server_b_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)), @@ -411,6 +413,7 @@ async fn test_repl_increment_basic_entry_tombstone(server_a: &QueryServer, serve assert!(server_b_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)), @@ -481,6 +484,7 @@ async fn test_repl_increment_consumer_lagging_tombstone( assert!(server_b_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)), @@ -584,6 +588,7 @@ async fn test_repl_increment_basic_bidirectional_write( assert!(server_b_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)), @@ -664,6 +669,7 @@ async fn test_repl_increment_basic_deleted_attr(server_a: &QueryServer, server_b assert!(server_a_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)), @@ -731,6 +737,7 @@ async fn test_repl_increment_simultaneous_bidirectional_write( assert!(server_b_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)), @@ -842,6 +849,7 @@ async fn test_repl_increment_basic_bidirectional_lifecycle( assert!(server_b_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)), @@ -983,6 +991,7 @@ async fn test_repl_increment_basic_bidirectional_recycle( assert!(server_b_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)), @@ -1109,6 +1118,7 @@ async fn test_repl_increment_basic_bidirectional_tombstone( assert!(server_b_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)), @@ -1213,6 +1223,7 @@ async fn test_repl_increment_creation_uuid_conflict( let t_uuid = Uuid::new_v4(); let e_init = 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)), @@ -1244,6 +1255,19 @@ async fn test_repl_increment_creation_uuid_conflict( .internal_search_all_uuid(t_uuid) .expect("Unable to access entry."); + let e1_acc = server_a_txn + .internal_search_all_uuid(UUID_IDM_ALL_ACCOUNTS) + .expect("Unable to access new entry."); + let e2_acc = server_b_txn + .internal_search_all_uuid(UUID_IDM_ALL_ACCOUNTS) + .expect("Unable to access entry."); + + trace!("TESTMARKER 0"); + trace!(?e1); + trace!(?e2); + trace!(?e1_acc); + trace!(?e2_acc); + trace!("{:?}", e1.get_last_changed()); trace!("{:?}", e2.get_last_changed()); // e2 from b will be smaller as it's the older entry. @@ -1277,6 +1301,19 @@ async fn test_repl_increment_creation_uuid_conflict( .internal_search_all_uuid(t_uuid) .expect("Unable to access entry."); + let e1_acc = server_a_txn + .internal_search_all_uuid(UUID_IDM_ALL_ACCOUNTS) + .expect("Unable to access new entry."); + let e2_acc = server_b_txn + .internal_search_all_uuid(UUID_IDM_ALL_ACCOUNTS) + .expect("Unable to access entry."); + + trace!("TESTMARKER 1"); + trace!(?e1); + trace!(?e2); + trace!(?e1_acc); + trace!(?e2_acc); + assert!(e1.get_last_changed() == e2.get_last_changed()); let cnf_a = server_a_txn @@ -1292,6 +1329,10 @@ async fn test_repl_increment_creation_uuid_conflict( .expect("Unable to conflict entries."); assert!(cnf_b.is_empty()); + trace!("TESTMARKER 2"); + trace!(?cnf_a); + trace!(?cnf_b); + server_a_txn.commit().expect("Failed to commit"); drop(server_b_txn); @@ -1319,8 +1360,32 @@ async fn test_repl_increment_creation_uuid_conflict( .pop() .expect("No conflict entries present"); + trace!("TESTMARKER 3"); + trace!(?cnf_a); + trace!(?cnf_b); + assert!(cnf_a.get_last_changed() == cnf_b.get_last_changed()); + let e1 = server_a_txn + .internal_search_all_uuid(t_uuid) + .expect("Unable to access new entry."); + let e2 = server_b_txn + .internal_search_all_uuid(t_uuid) + .expect("Unable to access entry."); + + let e1_acc = server_a_txn + .internal_search_all_uuid(UUID_IDM_ALL_ACCOUNTS) + .expect("Unable to access new entry."); + let e2_acc = server_b_txn + .internal_search_all_uuid(UUID_IDM_ALL_ACCOUNTS) + .expect("Unable to access entry."); + + trace!("TESTMARKER 4"); + trace!(?e1); + trace!(?e2); + trace!(?e1_acc); + trace!(?e2_acc); + server_b_txn.commit().expect("Failed to commit"); drop(server_a_txn); } @@ -1344,6 +1409,7 @@ async fn test_repl_increment_create_tombstone_uuid_conflict( let t_uuid = Uuid::new_v4(); let e_init = 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)), @@ -1437,6 +1503,7 @@ async fn test_repl_increment_create_tombstone_conflict( let t_uuid = Uuid::new_v4(); let e_init = 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)), @@ -1533,6 +1600,7 @@ async fn test_repl_increment_schema_conflict(server_a: &QueryServer, server_b: & assert!(server_b_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)), @@ -1570,8 +1638,10 @@ async fn test_repl_increment_schema_conflict(server_a: &QueryServer, server_b: & let mut server_b_txn = server_b.write(ct).await; let modlist = ModifyList::new_list(vec![ Modify::Removed(Attribute::Class.into(), EntryClass::Person.into()), + Modify::Removed(Attribute::Class.into(), EntryClass::Account.into()), Modify::Present(Attribute::Class.into(), EntryClass::Group.into()), Modify::Purged(Attribute::IdVerificationEcKey.into()), + Modify::Purged(Attribute::NameHistory.into()), Modify::Purged(Attribute::DisplayName.into()), ]); assert!(server_b_txn.internal_modify_uuid(t_uuid, &modlist).is_ok()); @@ -1650,6 +1720,7 @@ async fn test_repl_increment_consumer_lagging_attributes( assert!(server_b_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)), @@ -1775,6 +1846,7 @@ async fn test_repl_increment_consumer_ruv_trim_past_valid( assert!(server_b_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)), @@ -1905,6 +1977,7 @@ async fn test_repl_increment_consumer_ruv_trim_idle_servers( assert!(server_b_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)), diff --git a/server/lib/src/schema.rs b/server/lib/src/schema.rs index 3028640db..12c8e9421 100644 --- a/server/lib/src/schema.rs +++ b/server/lib/src/schema.rs @@ -1433,6 +1433,25 @@ impl<'a> SchemaWriteTransaction<'a> { syntax: SyntaxType::ReferenceUuid, }, ); + self.attributes.insert( + Attribute::RecycledDirectMemberOf.into(), + SchemaAttribute { + name: Attribute::RecycledDirectMemberOf.into(), + uuid: UUID_SCHEMA_ATTR_RECYCLEDDIRECTMEMBEROF, + description: String::from("recycled reverse direct group membership of the object to assist in revive operations."), + multivalue: true, + unique: false, + phantom: false, + sync_allowed: false, + // Unlike DMO this must be replicated so that on a recycle event, these groups + // "at delete" are replicated to partners. This avoids us having to replicate + // DMO which is very costly, while still retaining our ability to revive entries + // and their group memberships as a best effort. + replicated: true, + index: vec![], + syntax: SyntaxType::ReferenceUuid, + }, + ); self.attributes.insert( Attribute::Member.into(), SchemaAttribute { @@ -1974,6 +1993,7 @@ impl<'a> SchemaWriteTransaction<'a> { name: EntryClass::Recycled.into(), uuid: UUID_SCHEMA_CLASS_RECYCLED, description: String::from("An object that has been deleted, but still recoverable via the revive operation. Recycled objects are not modifiable, only revivable."), + systemmay: vec![Attribute::RecycledDirectMemberOf.into()], .. Default::default() }, ); diff --git a/server/lib/src/server/access/mod.rs b/server/lib/src/server/access/mod.rs index 0d3127b82..ec842ff56 100644 --- a/server/lib/src/server/access/mod.rs +++ b/server/lib/src/server/access/mod.rs @@ -2719,10 +2719,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(rs_uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -2764,10 +2761,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(rs_uuid)), - ( - Attribute::OAuth2RsName, - Value::new_iname("test_resource_server") - ), + (Attribute::Name, Value::new_iname("test_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("test_resource_server") @@ -2790,10 +2784,7 @@ mod tests { EntryClass::OAuth2ResourceServerBasic.to_value() ), (Attribute::Uuid, Value::Uuid(Uuid::new_v4())), - ( - Attribute::OAuth2RsName, - Value::new_iname("second_resource_server") - ), + (Attribute::Name, Value::new_iname("second_resource_server")), ( Attribute::DisplayName, Value::new_utf8s("second_resource_server") @@ -2832,14 +2823,14 @@ mod tests { let se_a = SearchEvent::new_impersonate_entry( E_TEST_ACCOUNT_1.clone(), - filter_all!(f_pres(Attribute::OAuth2RsName)), + filter_all!(f_pres(Attribute::Name)), ); let ex_a = vec![Arc::new(ev1)]; let ex_a_reduced = vec![ev1_reduced]; let se_b = SearchEvent::new_impersonate_entry( E_TEST_ACCOUNT_2.clone(), - filter_all!(f_pres(Attribute::OAuth2RsName)), + filter_all!(f_pres(Attribute::Name)), ); let ex_b = vec![]; diff --git a/server/lib/src/server/access/search.rs b/server/lib/src/server/access/search.rs index 6ef595f68..43e594604 100644 --- a/server/lib/src/server/access/search.rs +++ b/server/lib/src/server/access/search.rs @@ -191,7 +191,7 @@ fn search_oauth2_filter_entry<'a>( Attribute::Class.as_ref(), Attribute::DisplayName.as_ref(), Attribute::Uuid.as_ref(), - Attribute::OAuth2RsName.as_ref(), + Attribute::Name.as_ref(), Attribute::OAuth2RsOrigin.as_ref(), Attribute::OAuth2RsOriginLanding.as_ref(), Attribute::Image.as_ref() diff --git a/server/lib/src/server/delete.rs b/server/lib/src/server/delete.rs index b0bbc2e0b..e40290c8a 100644 --- a/server/lib/src/server/delete.rs +++ b/server/lib/src/server/delete.rs @@ -204,6 +204,7 @@ mod tests { let e1 = 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")), ( @@ -216,6 +217,7 @@ mod tests { let e2 = 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("testperson2")), ( @@ -228,6 +230,7 @@ mod tests { let e3 = 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("testperson3")), ( diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs index d57efd7bd..31597bc08 100644 --- a/server/lib/src/server/migrations.rs +++ b/server/lib/src/server/migrations.rs @@ -739,6 +739,84 @@ impl<'a> QueryServerWriteTransaction<'a> { }) } + #[instrument(level = "info", skip_all)] + /// Migrations for Oauth to move rs name from a dedicated type to name + /// and to allow oauth2 sessions on resource servers for client credentials + /// grants. Accounts, persons and service accounts have some attributes + /// relocated to allow oauth2 rs to become accounts. + pub fn migrate_domain_4_to_5(&mut self) -> Result<(), OperationError> { + let idm_schema_classes = [ + SCHEMA_CLASS_PERSON_DL5.clone().into(), + SCHEMA_CLASS_ACCOUNT_DL5.clone().into(), + SCHEMA_CLASS_SERVICE_ACCOUNT_DL5.clone().into(), + SCHEMA_CLASS_OAUTH2_RS_DL5.clone().into(), + SCHEMA_CLASS_OAUTH2_RS_BASIC_DL5.clone().into(), + IDM_ACP_OAUTH2_MANAGE_DL5.clone().into(), + ]; + + idm_schema_classes + .into_iter() + .try_for_each(|entry| self.internal_migrate_or_create(entry)) + .map_err(|err| { + error!(?err, "migrate_domain_4_to_5 -> Error"); + err + })?; + + // Reload mid txn so that the next modification works. + self.force_schema_reload(); + self.reload()?; + + // Now we remove attributes from service accounts that have been unable to be set + // via a user interface for more than a year. + let filter = filter!(f_and!([ + f_eq(Attribute::Class, EntryClass::Account.into()), + f_eq(Attribute::Class, EntryClass::ServiceAccount.into()), + ])); + let modlist = ModifyList::new_list(vec![ + Modify::Purged(Attribute::PassKeys.into()), + Modify::Purged(Attribute::AttestedPasskeys.into()), + Modify::Purged(Attribute::CredentialUpdateIntentToken.into()), + Modify::Purged(Attribute::RadiusSecret.into()), + ]); + self.internal_modify(&filter, &modlist)?; + + // Now move all oauth2 rs name. + let filter = filter!(f_eq( + Attribute::Class, + EntryClass::OAuth2ResourceServer.into() + )); + + let pre_candidates = self.internal_search(filter).map_err(|err| { + admin_error!(?err, "migrate_domain_4_to_5 internal search failure"); + err + })?; + + let modset: Vec<_> = pre_candidates + .into_iter() + .filter_map(|ent| { + ent.get_ava_single_iname(Attribute::OAuth2RsName) + .map(|rs_name| { + let modlist = vec![ + Modify::Present(Attribute::Class.into(), EntryClass::Account.into()), + Modify::Present(Attribute::Name.into(), Value::new_iname(rs_name)), + m_purge(Attribute::OAuth2RsName), + ]; + + (ent.get_uuid(), ModifyList::new_list(modlist)) + }) + }) + .collect(); + + // If there is nothing, we don't need to do anything. + if modset.is_empty() { + admin_info!("migrate_domain_4_to_5 no entries to migrate, complete"); + return Ok(()); + } + + // Apply the batch mod. + self.internal_batch_modify(modset.into_iter()) + } + #[instrument(level = "info", skip_all)] pub fn initialise_schema_core(&mut self) -> Result<(), OperationError> { admin_debug!("initialise_schema_core -> start ..."); diff --git a/server/lib/src/server/mod.rs b/server/lib/src/server/mod.rs index d3c689c3e..b53d33787 100644 --- a/server/lib/src/server/mod.rs +++ b/server/lib/src/server/mod.rs @@ -1159,9 +1159,9 @@ impl QueryServer { let d_info = Arc::new(CowCell::new(DomainInfo { d_uuid, - // Start with our minimum supported level. + // Start with our level as zero. // This will be reloaded from the DB shortly :) - d_vers: DOMAIN_MIN_LEVEL, + d_vers: DOMAIN_LEVEL_0, d_name: domain_name.clone(), // we set the domain_display_name to the configuration file's domain_name // here because the database is not started, so we cannot pull it from there. @@ -1630,6 +1630,10 @@ impl<'a> QueryServerWriteTransaction<'a> { self.migrate_domain_3_to_4()?; } + if previous_version <= DOMAIN_LEVEL_4 && domain_info_version >= DOMAIN_LEVEL_5 { + self.migrate_domain_4_to_5()?; + } + Ok(()) } @@ -1853,6 +1857,7 @@ mod tests { assert!(server_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)), @@ -1983,6 +1988,7 @@ mod tests { let mut server_txn = server.write(duration_from_epoch_now()).await; let e1 = 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")), ( diff --git a/server/lib/src/server/modify.rs b/server/lib/src/server/modify.rs index 84e8fd710..ebb425aa2 100644 --- a/server/lib/src/server/modify.rs +++ b/server/lib/src/server/modify.rs @@ -209,9 +209,21 @@ impl<'a> QueryServerWriteTransaction<'a> { if !self.changed_oauth2 { self.changed_oauth2 = norm_cand .iter() - .chain(pre_candidates.iter().map(|e| e.as_ref())) - .any(|e| { - e.attribute_equality(Attribute::Class, &EntryClass::OAuth2ResourceServer.into()) + .zip(pre_candidates.iter().map(|e| e.as_ref())) + .any(|(post, pre)| { + // This is in the modify path only - because sessions can update the RS + // this can trigger reloads of all the oauth2 clients. That would make + // client credentials grant pretty expensive in these cases. To avoid this + // we check if "anything else" beside the oauth2session changed in this + // txn. + (post.attribute_equality( + Attribute::Class, + &EntryClass::OAuth2ResourceServer.into(), + ) || pre.attribute_equality( + Attribute::Class, + &EntryClass::OAuth2ResourceServer.into(), + )) && post + .entry_changed_excluding_attribute(Attribute::OAuth2Session, &self.cid) }); } if !self.changed_domain { @@ -511,6 +523,7 @@ mod tests { let e1 = 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")), ( @@ -523,6 +536,7 @@ mod tests { let e2 = 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("testperson2")), ( @@ -671,6 +685,7 @@ mod tests { let e1 = 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")), ( diff --git a/server/lib/src/server/recycle.rs b/server/lib/src/server/recycle.rs index 256c1325a..8d756c10f 100644 --- a/server/lib/src/server/recycle.rs +++ b/server/lib/src/server/recycle.rs @@ -148,7 +148,7 @@ impl<'a> QueryServerWriteTransaction<'a> { // Get this entries uuid. let u: Uuid = e.get_uuid(); - if let Some(riter) = e.get_ava_as_refuuid(Attribute::DirectMemberOf) { + if let Some(riter) = e.get_ava_as_refuuid(Attribute::RecycledDirectMemberOf) { for g_uuid in riter { dm_mods .entry(g_uuid) @@ -291,6 +291,7 @@ mod tests { // Create some recycled objects let e1 = 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")), ( @@ -303,6 +304,7 @@ mod tests { let e2 = 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("testperson2")), ( @@ -397,6 +399,7 @@ mod tests { let e1 = 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")), ( @@ -532,6 +535,7 @@ mod tests { // First, create an entry, then push it through the lifecycle. let e_ts = 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")), ( @@ -610,6 +614,7 @@ mod tests { fn create_user(name: &str, uuid: &str) -> Entry { 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(name)), ( @@ -639,7 +644,7 @@ mod tests { } fn check_entry_has_mo(qs: &mut QueryServerWriteTransaction, name: &str, mo: &str) -> bool { - let e = qs + let entry = qs .internal_search(filter!(f_eq( Attribute::Name, PartialValue::new_iname(name) @@ -648,7 +653,9 @@ mod tests { .pop() .unwrap(); - e.attribute_equality(Attribute::MemberOf, &PartialValue::new_refer_s(mo).unwrap()) + trace!(?entry); + + entry.attribute_equality(Attribute::MemberOf, &PartialValue::new_refer_s(mo).unwrap()) } #[qs_test] diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs index a0b619f5a..681348b93 100644 --- a/server/lib/src/value.rs +++ b/server/lib/src/value.rs @@ -1030,8 +1030,7 @@ impl From for DbValueOauthClaimMapJoinV1 { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Oauth2Session { - pub parent: Uuid, - // pub expiry: Option, + pub parent: Option, pub state: SessionState, pub issued_at: OffsetDateTime, pub rs_uuid: Uuid, diff --git a/server/lib/src/valueset/session.rs b/server/lib/src/valueset/session.rs index 8ff783be2..709aeafe7 100644 --- a/server/lib/src/valueset/session.rs +++ b/server/lib/src/valueset/session.rs @@ -779,6 +779,8 @@ impl ValueSetOauth2Session { .map(SessionState::ExpiresAt) .unwrap_or(SessionState::NeverExpires); + let parent = Some(parent); + // Insert to the rs_filter. rs_filter |= rs_uuid.as_u128(); Some(( @@ -833,6 +835,8 @@ impl ValueSetOauth2Session { rs_filter |= rs_uuid.as_u128(); + let parent = Some(parent); + Some(( refer, Oauth2Session { @@ -842,7 +846,59 @@ impl ValueSetOauth2Session { rs_uuid, }, )) - } + } // End V2 + DbValueOauth2Session::V3 { + refer, + parent, + state, + issued_at, + rs_uuid, + } => { + // Convert things. + let issued_at = OffsetDateTime::parse(&issued_at, &Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid issued_at timestamp", + refer + ) + }) + .ok()?; + + let state = match state { + DbValueSessionStateV1::ExpiresAt(e_inner) => { + OffsetDateTime::parse(&e_inner, &Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + .map(SessionState::ExpiresAt) + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid expiry timestamp", + refer + ) + }) + .ok()? + } + DbValueSessionStateV1::Never => SessionState::NeverExpires, + DbValueSessionStateV1::RevokedAt(dc) => SessionState::RevokedAt(Cid { + s_uuid: dc.server_id, + ts: dc.timestamp, + }), + }; + + rs_filter |= rs_uuid.as_u128(); + + Some(( + refer, + Oauth2Session { + parent, + state, + issued_at, + rs_uuid, + }, + )) + } // End V3 } }) .collect(); @@ -1096,7 +1152,7 @@ impl ValueSetT for ValueSetOauth2Session { DbValueSetV2::Oauth2Session( self.map .iter() - .map(|(u, m)| DbValueOauth2Session::V2 { + .map(|(u, m)| DbValueOauth2Session::V3 { refer: *u, parent: m.parent, state: match &m.state { @@ -1936,7 +1992,7 @@ mod tests { Oauth2Session { state: SessionState::NeverExpires, issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -1966,7 +2022,7 @@ mod tests { Oauth2Session { state: SessionState::NeverExpires, issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -1976,7 +2032,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(zero_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -2001,7 +2057,7 @@ mod tests { Oauth2Session { state: SessionState::NeverExpires, issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -2011,7 +2067,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(zero_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -2039,7 +2095,7 @@ mod tests { Oauth2Session { state: SessionState::NeverExpires, issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -2050,7 +2106,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(one_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), @@ -2059,7 +2115,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(zero_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), @@ -2093,7 +2149,7 @@ mod tests { Oauth2Session { state: SessionState::NeverExpires, issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ); @@ -2104,7 +2160,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(one_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), @@ -2113,7 +2169,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(zero_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), @@ -2151,7 +2207,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(zero_cid), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), @@ -2160,7 +2216,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(one_cid), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), @@ -2169,7 +2225,7 @@ mod tests { Oauth2Session { state: SessionState::RevokedAt(two_cid.clone()), issued_at: OffsetDateTime::now_utc(), - parent: Uuid::new_v4(), + parent: Some(Uuid::new_v4()), rs_uuid: Uuid::new_v4(), }, ), diff --git a/server/testkit/tests/oauth2_test.rs b/server/testkit/tests/oauth2_test.rs index 40a90119f..8263dd1e2 100644 --- a/server/testkit/tests/oauth2_test.rs +++ b/server/testkit/tests/oauth2_test.rs @@ -1,5 +1,5 @@ #![deny(warnings)] -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::convert::TryFrom; use std::str::FromStr; @@ -346,7 +346,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { let response = client .post(rsclient.make_url("/oauth2/token/introspect")) - .basic_auth("test_integration", Some(client_secret)) + .basic_auth("test_integration", Some(client_secret.clone())) .form(&intr_request) .send() .await @@ -415,6 +415,59 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { assert!(userinfo == oidc); + // Step 6 - Show that our client can perform a client credentials grant + + let form_req: AccessTokenRequest = GrantTypeReq::ClientCredentials { + scope: Some(BTreeSet::from([ + "email".to_string(), + "read".to_string(), + "openid".to_string(), + ])), + } + .into(); + + let response = client + .post(rsclient.make_url("/oauth2/token")) + .basic_auth("test_integration", Some(client_secret.clone())) + .form(&form_req) + .send() + .await + .expect("Failed to send client credentials request."); + + assert!(response.status() == reqwest::StatusCode::OK); + + let atr = response + .json::() + .await + .expect("Unable to decode AccessTokenResponse"); + + // Step 7 - inspect the granted client credentials token. + let intr_request = AccessTokenIntrospectRequest { + token: atr.access_token.clone(), + token_type_hint: None, + }; + + let response = client + .post(rsclient.make_url("/oauth2/token/introspect")) + .basic_auth("test_integration", Some(client_secret)) + .form(&intr_request) + .send() + .await + .expect("Failed to send token introspect request."); + + assert!(response.status() == reqwest::StatusCode::OK); + + let tir = response + .json::() + .await + .expect("Unable to decode AccessTokenIntrospectResponse"); + + assert!(tir.active); + assert!(tir.scope.is_some()); + assert!(tir.client_id.as_deref() == Some("test_integration")); + assert!(tir.username.as_deref() == Some("test_integration@localhost")); + assert!(tir.token_type.as_deref() == Some("access_token")); + // auth back with admin so we can test deleting things let res = rsclient .auth_simple_password("admin", ADMIN_TEST_PASSWORD)