diff --git a/kanidm_book/src/developers/designs/elevated_priv_mode.md b/kanidm_book/src/developers/designs/elevated_priv_mode.md new file mode 100644 index 000000000..be8be3b4e --- /dev/null +++ b/kanidm_book/src/developers/designs/elevated_priv_mode.md @@ -0,0 +1,116 @@ +# Elevation of Privilege Inside User Sessions + +To improve user experience, we need to allow long lived sessions in browsers. This is especially +important as a single sign on system, users tend to be associated 1 to 1 with devices, and by having +longer lived sessions, they have a smoother experience. + +However, we also don't want user sessions to have unbound write permissions for the entire (possibly +unlimited) duration of their session. + +Prior art for this is github, which has unbounded sessions on machines and requests a +re-authentication when a modifying or sensitive action is to occur. + +For us to implement this will require some changes to how we manage sessions. + +## Session Issuance + +- ISSUE: Sessions are issued identically for service-accounts and persons +- CHANGE: service-accounts require a hard/short session expiry limit and always have elevated + permissions +- CHANGE: persons require no session expiry and must request elevation for privs. + +- ISSUE: Sessions currently indicate all read-write types as the same access scope type. +- CHANGE: Split sessions to show rwalways, rwcapable, rwactive + +- ISSUE: Sessions currently are recorded identically between service-accounts, persons, and api + tokens +- CHANGE: Change the session storage types to have unique session types for these ✅ + +- ISSUE: Access Scope types are confused by api session using the same types. +- CHANGE: Use access scope only as the end result of current effective permission calculation and + not as a method to convert to anything else. ✅ + + AccessScope { ReadOnly, ReadWrite, Synchronise } + + // Bound by token expiry ApiTokenScope { ReadOnly, ReadWrite, Synchronise } + + UatTokenScope { ReadOnly, // Want to avoid "read write" here to prevent dev confusion. + PrivilegeCapable, PrivilegeActive { expiry }, ReadWrite, } + + SessionScope { Ro, RwAlways, PrivCapable, } + + ApiTokenScope { RO RW Sync } + + AuthSession if service account rw always, bound expiry + + if person + priv cap, unbound exp + - Should we have a "trust the machine flag" to limit exp though? + - can we do other types of cryptographic session binding? + +## Session Validation + +- CHANGE: Session with PrivCapable indicates that re-auth can be performed. +- CHANGE: Improve how Uat/Api Token scopes become Access Scopes +- CHANGE: Remove all AccessScope into other types. ✅ + +## Session Re-Authentication + +- Must be performed by the same credential that issued the session originally + - This is now stored in the session metadata itself. + - Does it need to be in the cred-id? + +- CHANGE: Store the cred id in UAT so that a replica can process the operation in a replication sync + failure? + - This would rely on re-writing the session. +- CHANGE: Should we record in the session when priv-escalations are performed? + +## Misc + +- CHANGE: Compact/shrink UAT size if possible. + +## Diagram + + Set + ┌───────────────────────PrivActive────────────────────┐ + │ + Exp │ + │ │ + ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ .───────────. ┌────────────────┐ + │ ┌────────────────▶( If Priv Cap )───────▶│Re-Auth-Allowed │ + │ │ │ │ `───────────' └────────────────┘ + DB Content ┌ ─ ─ ─ ┼ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ + ┌───────────────────┐ │ │ JWT │ │ │ + │ │ │ ▼ │ + │ AuthSession │ │ ┌──────────────┐ │ ┌──────────────┐ │ + │ │ │SessionScope │ │ │UatScope │ + │ Service Account │ │ │- RO │ │ │- RO │ │ + │ -> RWAlways │──────────────────▶│- RW │─────────┼──▶│- RW │──────────────────────────┐ + │ │ │ │- PrivCapable │ │ │- PrivCapable │ │ │ + │ Person │ └──────────────┘ │ │- PrivActive │ │ + │ -> PrivCap │ │ │ └──────────────┘ │ │ + │ │ │ │ + └───────────────────┘ │ │ │ ▼ + │ ┌──────────────┐ + │ │ │ │AccessScope │ ┌───────────────┐ + │ │- RO │ │ │ + │ │ │ │- RW │───────────▶ │Access Controls│ + │ │- Sync │ │ │ + ┌───────────────────┐ │ ┌─────────────────┐ │ ┌──────────────┐ │ └──────────────┘ └───────────────┘ + │ │ │ApiSessionScope │ │ │ApiTokenScope │ ▲ + │ Create API Token │ │ │- RO │ │ │- RO │ │ │ + │ │───────────────▶│- RW │────────┼───▶│- RW │─────────────────────────┘ + │Access Based On Req│ │ │- Sync │ │ │- Sync │ │ + │ │ └─────────────────┘ │ │ │ + └───────────────────┘ │ │ └──────────────┘ │ + │ + │ │ │ + ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +## TODO: + +1. Remove the ident-only access scope, it's useless! ✅ +1. Split tokens to have a dedicated session type separate to uat sessions. ✅ +1. Change uat session access scope recording to match service-account vs person intent. +1. Change UAT session issuance to have the uat purpose reflect the readwrite or readwrite-capable + nature of the session, based on _auth-type_ that was used. +1. Based on auth-type, limit or unlimit expiry to match the intent of the session. diff --git a/kanidm_proto/src/v1.rs b/kanidm_proto/src/v1.rs index 301dfff8e..8f55e38b5 100644 --- a/kanidm_proto/src/v1.rs +++ b/kanidm_proto/src/v1.rs @@ -358,9 +358,9 @@ impl FromStr for UiHint { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum UatPurposeStatus { - IdentityOnly, ReadOnly, ReadWrite, + PrivilegeCapable, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -386,9 +386,9 @@ impl fmt::Display for UatStatus { } writeln!(f, "issued_at: {}", self.issued_at)?; match &self.purpose { - UatPurposeStatus::IdentityOnly => writeln!(f, "purpose: identity only")?, UatPurposeStatus::ReadOnly => writeln!(f, "purpose: read only")?, UatPurposeStatus::ReadWrite => writeln!(f, "purpose: read write")?, + UatPurposeStatus::PrivilegeCapable => writeln!(f, "purpose: privilege capable")?, } Ok(()) } @@ -397,7 +397,6 @@ impl fmt::Display for UatStatus { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] pub enum UatPurpose { - IdentityOnly, ReadOnly, ReadWrite { /// If none, there is no expiry, and this is always rw. If there is @@ -446,7 +445,6 @@ impl fmt::Display for UserAuthToken { writeln!(f, "expiry: -")?; } match &self.purpose { - UatPurpose::IdentityOnly => writeln!(f, "purpose: identity only")?, UatPurpose::ReadOnly => writeln!(f, "purpose: read only")?, UatPurpose::ReadWrite { expiry: Some(expiry), diff --git a/kanidmd/lib/src/be/dbvalue.rs b/kanidmd/lib/src/be/dbvalue.rs index 884849fde..31024b612 100644 --- a/kanidmd/lib/src/be/dbvalue.rs +++ b/kanidmd/lib/src/be/dbvalue.rs @@ -391,6 +391,8 @@ pub enum DbValueAccessScopeV1 { ReadOnly, #[serde(rename = "w")] ReadWrite, + #[serde(rename = "p")] + PrivilegeCapable, #[serde(rename = "s")] Synchronise, } @@ -439,6 +441,35 @@ pub enum DbValueSession { }, } +#[derive(Serialize, Deserialize, Debug, Default)] +pub enum DbValueApiTokenScopeV1 { + #[serde(rename = "r")] + #[default] + ReadOnly, + #[serde(rename = "w")] + ReadWrite, + #[serde(rename = "s")] + Synchronise, +} + +#[derive(Serialize, Deserialize, Debug)] +pub enum DbValueApiToken { + V1 { + #[serde(rename = "u")] + refer: Uuid, + #[serde(rename = "l")] + label: String, + #[serde(rename = "e")] + expiry: Option, + #[serde(rename = "i")] + issued_at: String, + #[serde(rename = "b")] + issued_by: DbValueIdentityId, + #[serde(rename = "s", default)] + scope: DbValueApiTokenScopeV1, + }, +} + #[derive(Serialize, Deserialize, Debug)] pub enum DbValueOauth2Session { V1 { @@ -594,6 +625,8 @@ pub enum DbValueSetV2 { UiHint(Vec), #[serde(rename = "TO")] TotpSecret(Vec<(String, DbTotpV1)>), + #[serde(rename = "AT")] + ApiToken(Vec), } impl DbValueSetV2 { @@ -630,6 +663,7 @@ impl DbValueSetV2 { DbValueSetV2::DeviceKey(set) => set.len(), DbValueSetV2::TrustedDeviceEnrollment(set) => set.len(), DbValueSetV2::Session(set) => set.len(), + DbValueSetV2::ApiToken(set) => set.len(), DbValueSetV2::Oauth2Session(set) => set.len(), DbValueSetV2::JwsKeyEs256(set) => set.len(), DbValueSetV2::JwsKeyRs256(set) => set.len(), diff --git a/kanidmd/lib/src/constants/entries.rs b/kanidmd/lib/src/constants/entries.rs index e6785f695..5be7c0d15 100644 --- a/kanidmd/lib/src/constants/entries.rs +++ b/kanidmd/lib/src/constants/entries.rs @@ -565,7 +565,7 @@ pub const JSON_SYSTEM_INFO_V1: &str = r#"{ "class": ["object", "system_info", "system"], "uuid": ["00000000-0000-0000-0000-ffffff000001"], "description": ["System (local) info and metadata object."], - "version": ["11"] + "version": ["12"] } }"#; diff --git a/kanidmd/lib/src/constants/schema.rs b/kanidmd/lib/src/constants/schema.rs index 628114b1d..c790f0593 100644 --- a/kanidmd/lib/src/constants/schema.rs +++ b/kanidmd/lib/src/constants/schema.rs @@ -1233,7 +1233,7 @@ pub const JSON_SCHEMA_ATTR_API_TOKEN_SESSION: &str = r#"{ "api_token_session" ], "syntax": [ - "SESSION" + "APITOKEN" ], "uuid": [ "00000000-0000-0000-0000-ffff00000111" @@ -1326,7 +1326,7 @@ pub const JSON_SCHEMA_ATTR_SYNC_TOKEN_SESSION: &str = r#"{ "sync_token_session" ], "syntax": [ - "SESSION" + "APITOKEN" ], "uuid": [ "00000000-0000-0000-0000-ffff00000115" diff --git a/kanidmd/lib/src/entry.rs b/kanidmd/lib/src/entry.rs index 9a3eb9fbe..a7e322c55 100644 --- a/kanidmd/lib/src/entry.rs +++ b/kanidmd/lib/src/entry.rs @@ -58,7 +58,7 @@ use crate::repl::entry::EntryChangeState; use crate::schema::{SchemaAttribute, SchemaClass, SchemaTransaction}; use crate::value::{ - IndexType, IntentTokenState, Oauth2Session, PartialValue, Session, SyntaxType, Value, + ApiToken, IndexType, IntentTokenState, Oauth2Session, PartialValue, Session, SyntaxType, Value, }; use crate::valueset::{self, ValueSet}; @@ -1961,6 +1961,14 @@ impl Entry { self.attrs.get(attr).and_then(|vs| vs.as_session_map()) } + #[inline(always)] + pub fn get_ava_as_apitoken_map( + &self, + attr: &str, + ) -> Option<&std::collections::BTreeMap> { + self.attrs.get(attr).and_then(|vs| vs.as_apitoken_map()) + } + #[inline(always)] pub fn get_ava_as_oauth2session_map( &self, diff --git a/kanidmd/lib/src/idm/account.rs b/kanidmd/lib/src/idm/account.rs index 70e9a8ad4..8047824f0 100644 --- a/kanidmd/lib/src/idm/account.rs +++ b/kanidmd/lib/src/idm/account.rs @@ -209,7 +209,9 @@ impl Account { // TODO: Apply policy to this expiry time. let expiry = expiry_secs .map(|offset| OffsetDateTime::unix_epoch() + ct + Duration::from_secs(offset)); + let issued_at = OffsetDateTime::unix_epoch() + ct; + // TODO: Apply priv expiry, and what type of token this is (ident, ro, rw). let purpose = UatPurpose::ReadWrite { expiry }; diff --git a/kanidmd/lib/src/idm/authsession.rs b/kanidmd/lib/src/idm/authsession.rs index 5cc6deb51..cd2e6ee21 100644 --- a/kanidmd/lib/src/idm/authsession.rs +++ b/kanidmd/lib/src/idm/authsession.rs @@ -856,6 +856,10 @@ impl AuthSession { let session_id = Uuid::new_v4(); let issue = self.issue; + // We need to actually work this out better, and then + // pass it to to_userauthtoken + let scope = SessionScope::ReadWrite; + security_info!( "Issuing {:?} session {} for {} {}", issue, @@ -901,7 +905,7 @@ impl AuthSession { expiry: uat.expiry, issued_at: uat.issued_at, issued_by: IdentityId::User(self.account.uuid), - scope: (&uat.purpose).into(), + scope, })) .map_err(|_| { admin_error!("unable to queue failing authentication as the session will not validate ... "); diff --git a/kanidmd/lib/src/idm/credupdatesession.rs b/kanidmd/lib/src/idm/credupdatesession.rs index 1b7cb5078..e7db7c7a9 100644 --- a/kanidmd/lib/src/idm/credupdatesession.rs +++ b/kanidmd/lib/src/idm/credupdatesession.rs @@ -148,7 +148,7 @@ impl CredentialUpdateSession { } } -enum MfaRegStateStatus { +pub enum MfaRegStateStatus { // Nothing in progress. None, TotpCheck(TotpSecret), @@ -185,6 +185,16 @@ pub struct CredentialUpdateSessionStatus { mfaregstate: MfaRegStateStatus, } +impl CredentialUpdateSessionStatus { + pub fn can_commit(&self) -> bool { + self.can_commit + } + + pub fn mfaregstate(&self) -> &MfaRegStateStatus { + &self.mfaregstate + } +} + // We allow Into here because CUStatus is foreign so it's impossible for us to implement From // in a valid manner #[allow(clippy::from_over_into)] @@ -2017,7 +2027,7 @@ mod tests { let ct = Duration::from_secs(TEST_CURRENT_TIME); let (cust, _) = setup_test_session(idms, ct).await; - let cutxn = idms.cred_update_transaction(); + let cutxn = idms.cred_update_transaction_async().await; // The session exists let c_status = cutxn.credential_update_status(&cust, ct); assert!(c_status.is_ok()); diff --git a/kanidmd/lib/src/idm/delayed.rs b/kanidmd/lib/src/idm/delayed.rs index c8b69a572..bee845088 100644 --- a/kanidmd/lib/src/idm/delayed.rs +++ b/kanidmd/lib/src/idm/delayed.rs @@ -70,7 +70,7 @@ pub struct AuthSessionRecord { pub expiry: Option, pub issued_at: OffsetDateTime, pub issued_by: IdentityId, - pub scope: AccessScope, + pub scope: SessionScope, } #[derive(Debug)] diff --git a/kanidmd/lib/src/idm/mod.rs b/kanidmd/lib/src/idm/mod.rs index 3e7d13835..201085cd6 100644 --- a/kanidmd/lib/src/idm/mod.rs +++ b/kanidmd/lib/src/idm/mod.rs @@ -13,6 +13,7 @@ pub mod group; pub mod ldap; pub mod oauth2; pub mod radius; +pub mod reauth; pub mod scim; pub mod server; pub mod serviceaccount; diff --git a/kanidmd/lib/src/idm/reauth.rs b/kanidmd/lib/src/idm/reauth.rs new file mode 100644 index 000000000..1c5e1ff56 --- /dev/null +++ b/kanidmd/lib/src/idm/reauth.rs @@ -0,0 +1,233 @@ +use crate::prelude::*; + +use crate::idm::event::AuthResult; +use crate::idm::server::IdmServerAuthTransaction; + +#[derive(Debug)] +pub struct ReauthEvent { + // pub ident: Option, + // pub step: AuthEventStep, + // pub sessionid: Option, +} + +impl<'a> IdmServerAuthTransaction<'a> { + pub async fn reauth( + &mut self, + _ae: &ReauthEvent, + _ct: Duration, + ) -> Result { + todo!(); + } +} + +#[cfg(test)] +mod tests { + use crate::idm::credupdatesession::{InitCredentialUpdateEvent, MfaRegStateStatus}; + use crate::idm::delayed::DelayedAction; + use crate::idm::event::{AuthEvent, AuthResult}; + use crate::idm::server::IdmServerTransaction; + use crate::idm::AuthState; + use crate::prelude::*; + + use kanidm_proto::v1::{AuthAllowed, AuthIssueSession, AuthMech}; + + use uuid::uuid; + + use webauthn_authenticator_rs::softpasskey::SoftPasskey; + use webauthn_authenticator_rs::WebauthnAuthenticator; + + const TESTPERSON_UUID: Uuid = uuid!("cf231fea-1a8f-4410-a520-fd9b1a379c86"); + + async fn setup_testaccount(idms: &IdmServer, ct: Duration) { + let mut idms_prox_write = idms.proxy_write(ct).await; + + let e2 = entry_init!( + ("class", Value::new_class("object")), + ("class", Value::new_class("account")), + ("class", Value::new_class("person")), + ("name", Value::new_iname("testperson")), + ("uuid", Value::Uuid(TESTPERSON_UUID)), + ("description", Value::new_utf8s("testperson")), + ("displayname", Value::new_utf8s("testperson")) + ); + + let cr = idms_prox_write.qs_write.internal_create(vec![e2]); + assert!(cr.is_ok()); + assert!(idms_prox_write.commit().is_ok()); + } + + async fn setup_testaccount_passkey( + idms: &IdmServer, + ct: Duration, + ) -> WebauthnAuthenticator { + let mut idms_prox_write = idms.proxy_write(ct).await; + let testperson = idms_prox_write + .qs_write + .internal_search_uuid(TESTPERSON_UUID) + .expect("failed"); + let (cust, _c_status) = idms_prox_write + .init_credential_update( + &InitCredentialUpdateEvent::new_impersonate_entry(testperson), + ct, + ) + .expect("Failed to begin credential update."); + idms_prox_write.commit().expect("Failed to commit txn"); + + // Update session is setup. + + let cutxn = idms.cred_update_transaction(); + let origin = cutxn.get_origin().clone(); + + let mut wa = WebauthnAuthenticator::new(SoftPasskey::new()); + + let c_status = cutxn + .credential_passkey_init(&cust, ct) + .expect("Failed to initiate passkey registration"); + + let passkey_chal = match c_status.mfaregstate() { + MfaRegStateStatus::Passkey(c) => Some(c), + _ => None, + } + .expect("Unable to access passkey challenge, invalid state"); + + let passkey_resp = wa + .do_registration(origin.clone(), passkey_chal.clone()) + .expect("Failed to create soft passkey"); + + // Finish the registration + let label = "softtoken".to_string(); + let c_status = cutxn + .credential_passkey_finish(&cust, ct, label, &passkey_resp) + .expect("Failed to initiate passkey registration"); + + assert!(c_status.can_commit()); + + drop(cutxn); + let mut idms_prox_write = idms.proxy_write(ct).await; + + idms_prox_write + .commit_credential_update(&cust, ct) + .expect("Failed to commit credential update."); + + idms_prox_write.commit().expect("Failed to commit txn"); + + wa + } + + async fn auth_passkey( + idms: &IdmServer, + ct: Duration, + wa: &mut WebauthnAuthenticator, + idms_delayed: &mut IdmServerDelayed, + ) -> Option { + let mut idms_auth = idms.auth(); + let origin = idms_auth.get_origin().clone(); + + let auth_init = AuthEvent::named_init("testperson"); + + let r1 = idms_auth.auth(&auth_init, ct).await; + let ar = r1.unwrap(); + let AuthResult { + sessionid, + state, + delay: _, + } = ar; + + if !matches!(state, AuthState::Choose(_)) { + debug!("Can't proceed - {:?}", state); + return None; + }; + + let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::Passkey); + + let r2 = idms_auth.auth(&auth_begin, ct).await; + let ar = r2.unwrap(); + let AuthResult { + sessionid, + state, + delay: _, + } = ar; + + trace!(?state); + + let rcr = match state { + AuthState::Continue(mut allowed) => match allowed.pop() { + Some(AuthAllowed::Passkey(rcr)) => rcr, + _ => unreachable!(), + }, + _ => unreachable!(), + }; + + trace!(?rcr); + + let resp = wa + .do_authentication(origin, rcr) + .expect("failed to use softtoken to authenticate"); + + let passkey_step = AuthEvent::cred_step_passkey(sessionid, resp); + + let r3 = idms_auth.auth(&passkey_step, ct).await; + debug!("r3 ==> {:?}", r3); + idms_auth.commit().expect("Must not fail"); + + match r3 { + Ok(AuthResult { + sessionid: _, + state: AuthState::Success(token, AuthIssueSession::Token), + delay: _, + }) => { + // Process the webauthn update + let da = idms_delayed.try_recv().expect("invalid"); + assert!(matches!(da, DelayedAction::WebauthnCounterIncrement(_))); + let r = idms.delayed_action(ct, da).await; + assert!(r.is_ok()); + + // Process the auth session + let da = idms_delayed.try_recv().expect("invalid"); + assert!(matches!(da, DelayedAction::AuthSessionRecord(_))); + // We have to actually write this one else the following tests + // won't work! + let r = idms.delayed_action(ct, da).await; + assert!(r.is_ok()); + + Some(token) + } + _ => None, + } + } + + async fn token_to_ident(idms: &IdmServer, ct: Duration, token: Option<&str>) -> Identity { + let mut idms_prox_read = idms.proxy_read().await; + + idms_prox_read + .validate_and_parse_token_to_ident(token, ct) + .expect("Invalid UAT") + } + + #[idm_test] + async fn test_idm_reauth_passkey(idms: &IdmServer, idms_delayed: &mut IdmServerDelayed) { + let ct = duration_from_epoch_now(); + + // Setup the test account + setup_testaccount(idms, ct).await; + let mut passkey = setup_testaccount_passkey(idms, ct).await; + + // Do an initial auth. + let token = auth_passkey(idms, ct, &mut passkey, idms_delayed) + .await + .expect("failed to authenticate with passkey"); + + // Token_str to uat + let ident = token_to_ident(idms, ct, Some(token.as_str())).await; + + // Check that the rw entitlement is not present, and that re-auth is allowed. + // assert!(matches!(ident.access_scope(), AccessScope::ReadOnly)); + assert!(matches!(ident.access_scope(), AccessScope::ReadWrite)); + + // Assert the session is rw capable though. + + // Do a re-auth + + // They now have the entitlement. + } +} diff --git a/kanidmd/lib/src/idm/scim.rs b/kanidmd/lib/src/idm/scim.rs index fac6dc1fd..ccd9ed8a7 100644 --- a/kanidmd/lib/src/idm/scim.rs +++ b/kanidmd/lib/src/idm/scim.rs @@ -11,7 +11,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::credential::totp::{Totp, TotpAlgo, TotpDigits}; use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction}; use crate::prelude::*; -use crate::value::Session; +use crate::value::ApiToken; use crate::schema::{SchemaClass, SchemaTransaction}; @@ -21,7 +21,7 @@ use crate::schema::{SchemaClass, SchemaTransaction}; pub(crate) struct SyncAccount { pub name: String, pub uuid: Uuid, - pub sync_tokens: BTreeMap, + pub sync_tokens: BTreeMap, pub jws_key: JwsSigner, } @@ -49,7 +49,7 @@ macro_rules! try_from_entry { ))?; let sync_tokens = $value - .get_ava_as_session_map("sync_token_session") + .get_ava_as_apitoken_map("sync_token_session") .cloned() .unwrap_or_default(); @@ -83,7 +83,7 @@ impl SyncAccount { // Get the sessions. There are no gracewindows on sync, we are much stricter. let session_present = entry - .get_ava_as_session_map("sync_token_session") + .get_ava_as_apitoken_map("sync_token_session") .map(|session_map| session_map.get(&sst.token_id).is_some()) .unwrap_or(false); @@ -146,11 +146,12 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { let session_id = Uuid::new_v4(); let issued_at = time::OffsetDateTime::unix_epoch() + ct; - let purpose = ApiTokenPurpose::Synchronise; + let scope = ApiTokenScope::Synchronise; + let purpose = scope.try_into()?; - let session = Value::Session( + let session = Value::ApiToken( session_id, - Session { + ApiToken { label: gte.label.clone(), expiry: None, // Need the other inner bits? @@ -158,11 +159,9 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { issued_at, // Who actually created this? issued_by: gte.ident.get_event_origin_id(), - // random id - cred_id: Uuid::new_v4(), // What is the access scope of this session? This is // for auditing purposes. - scope: (&purpose).into(), + scope, }, ); @@ -544,7 +543,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { }; match sse.ident.access_scope() { - AccessScope::IdentityOnly | AccessScope::ReadOnly | AccessScope::ReadWrite => { + AccessScope::ReadOnly | AccessScope::ReadWrite => { warn!("Ident access scope is not synchronise"); return Err(OperationError::AccessDenied); } @@ -1347,7 +1346,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { }; match ident.access_scope() { - AccessScope::IdentityOnly | AccessScope::ReadOnly | AccessScope::ReadWrite => { + AccessScope::ReadOnly | AccessScope::ReadWrite => { warn!("Ident access scope is not synchronise"); return Err(OperationError::AccessDenied); } @@ -1550,7 +1549,7 @@ mod tests { .expect("Missing attribute: jws_es256_private_key"); let sync_tokens = sync_entry - .get_ava_as_session_map("sync_token_session") + .get_ava_as_apitoken_map("sync_token_session") .cloned() .unwrap_or_default(); diff --git a/kanidmd/lib/src/idm/server.rs b/kanidmd/lib/src/idm/server.rs index 879b97536..2ee381bd6 100644 --- a/kanidmd/lib/src/idm/server.rs +++ b/kanidmd/lib/src/idm/server.rs @@ -670,7 +670,6 @@ pub trait IdmServerTransaction<'a> { // ✅ Session is valid! Start to setup for it to be used. let scope = match uat.purpose { - UatPurpose::IdentityOnly => AccessScope::IdentityOnly, UatPurpose::ReadOnly => AccessScope::ReadOnly, UatPurpose::ReadWrite { expiry: None } => AccessScope::ReadWrite, UatPurpose::ReadWrite { @@ -898,6 +897,11 @@ impl<'a> IdmServerAuthTransaction<'a> { session_read.contains_key(&sessionid) } + pub fn get_origin(&self) -> &Url { + #[allow(clippy::unwrap_used)] + self.webauthn.get_allowed_origins().get(0).unwrap() + } + #[instrument(level = "trace", skip(self))] pub async fn expire_auth_sessions(&mut self, ct: Duration) { // ct is current time - sub the timeout. and then split. @@ -3692,7 +3696,7 @@ mod tests { expiry: Some(OffsetDateTime::unix_epoch() + expiry_a), issued_at: OffsetDateTime::unix_epoch() + ct, issued_by: IdentityId::User(UUID_ADMIN), - scope: AccessScope::IdentityOnly, + scope: SessionScope::ReadOnly, }); // Persist it. let r = task::block_on(idms.delayed_action(ct, da)); @@ -3727,7 +3731,7 @@ mod tests { expiry: Some(OffsetDateTime::unix_epoch() + expiry_b), issued_at: OffsetDateTime::unix_epoch() + ct, issued_by: IdentityId::User(UUID_ADMIN), - scope: AccessScope::IdentityOnly, + scope: SessionScope::ReadOnly, }); // Persist it. let r = task::block_on(idms.delayed_action(expiry_a, da)); diff --git a/kanidmd/lib/src/idm/serviceaccount.rs b/kanidmd/lib/src/idm/serviceaccount.rs index 867cc6eee..c387b7966 100644 --- a/kanidmd/lib/src/idm/serviceaccount.rs +++ b/kanidmd/lib/src/idm/serviceaccount.rs @@ -2,14 +2,14 @@ use std::collections::BTreeMap; use std::time::Duration; use compact_jwt::{Jws, JwsSigner}; -use kanidm_proto::v1::{ApiToken, ApiTokenPurpose}; +use kanidm_proto::v1::ApiToken as ProtoApiToken; use time::OffsetDateTime; use crate::event::SearchEvent; use crate::idm::account::Account; use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction}; use crate::prelude::*; -use crate::value::Session; +use crate::value::ApiToken; // Need to add KID to es256 der for lookups ✅ @@ -47,7 +47,7 @@ macro_rules! try_from_entry { ))?; let api_tokens = $value - .get_ava_as_session_map("api_token_session") + .get_ava_as_apitoken_map("api_token_session") .cloned() .unwrap_or_default(); @@ -75,7 +75,7 @@ pub struct ServiceAccount { pub valid_from: Option, pub expire: Option, - pub api_tokens: BTreeMap, + pub api_tokens: BTreeMap, pub jws_key: JwsSigner, } @@ -92,7 +92,7 @@ impl ServiceAccount { pub(crate) fn check_api_token_valid( ct: Duration, - apit: &ApiToken, + apit: &ProtoApiToken, entry: &Entry, ) -> bool { let within_valid_window = Account::check_within_valid_time( @@ -108,7 +108,7 @@ impl ServiceAccount { // Get the sessions. let session_present = entry - .get_ava_as_session_map("api_token_session") + .get_ava_as_apitoken_map("api_token_session") .map(|session_map| session_map.get(&apit.token_id).is_some()) .unwrap_or(false); @@ -207,16 +207,17 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { // Normalise to UTC in case it was provided as something else. let expiry = gte.expiry.map(|odt| odt.to_offset(time::UtcOffset::UTC)); - let purpose = if gte.read_write { - ApiTokenPurpose::ReadWrite + let scope = if gte.read_write { + ApiTokenScope::ReadWrite } else { - ApiTokenPurpose::ReadOnly + ApiTokenScope::ReadOnly }; + let purpose = scope.try_into()?; // create a new session - let session = Value::Session( + let session = Value::ApiToken( session_id, - Session { + ApiToken { label: gte.label.clone(), expiry, // Need the other inner bits? @@ -224,16 +225,14 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { issued_at, // Who actually created this? issued_by: gte.ident.get_event_origin_id(), - // random id - cred_id: Uuid::new_v4(), // What is the access scope of this session? This is // for auditing purposes. - scope: (&purpose).into(), + scope, }, ); // create the session token (not yet signed) - let token = Jws::new(ApiToken { + let token = Jws::new(ProtoApiToken { account_id: service_account.uuid, token_id: session_id, label: gte.label.clone(), @@ -312,7 +311,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { pub fn service_account_list_api_token( &mut self, lte: &ListApiTokenEvent, - ) -> Result, OperationError> { + ) -> Result, OperationError> { // Make an event from the request let srch = match SearchEvent::from_target_uuid_request( lte.ident.clone(), @@ -334,12 +333,12 @@ impl<'a> IdmServerProxyReadTransaction<'a> { .and_then(|e| { let account_id = e.get_uuid(); // From the entry, turn it into the value - e.get_ava_as_session_map("api_token_session").map(|smap| { + e.get_ava_as_apitoken_map("api_token_session").map(|smap| { smap.iter() .map(|(u, s)| { s.scope .try_into() - .map(|purpose| ApiToken { + .map(|purpose| ProtoApiToken { account_id, token_id: *u, label: s.label.clone(), diff --git a/kanidmd/lib/src/lib.rs b/kanidmd/lib/src/lib.rs index 3357cb019..c1a33a789 100644 --- a/kanidmd/lib/src/lib.rs +++ b/kanidmd/lib/src/lib.rs @@ -89,7 +89,9 @@ pub mod prelude { QueryServerWriteTransaction, }; pub use crate::utils::duration_from_epoch_now; - pub use crate::value::{IndexType, PartialValue, SyntaxType, Value}; + pub use crate::value::{ + ApiTokenScope, IndexType, PartialValue, SessionScope, SyntaxType, Value, + }; pub use crate::valueset::{ ValueSet, ValueSetBool, ValueSetCid, ValueSetIndex, ValueSetIutf8, ValueSetRefer, ValueSetSecret, ValueSetSpn, ValueSetSyntax, ValueSetT, ValueSetUint32, ValueSetUtf8, diff --git a/kanidmd/lib/src/plugins/refint.rs b/kanidmd/lib/src/plugins/refint.rs index 8f22c3697..c9766ceb4 100644 --- a/kanidmd/lib/src/plugins/refint.rs +++ b/kanidmd/lib/src/plugins/refint.rs @@ -834,7 +834,7 @@ mod tests { let pv_parent_id = PartialValue::Refer(parent); let issued_at = curtime_odt; let issued_by = IdentityId::User(tuuid); - let scope = AccessScope::IdentityOnly; + let scope = SessionScope::ReadOnly; // Mod the user let modlist = modlist!([ diff --git a/kanidmd/lib/src/plugins/session.rs b/kanidmd/lib/src/plugins/session.rs index 959b8be56..bff5e9379 100644 --- a/kanidmd/lib/src/plugins/session.rs +++ b/kanidmd/lib/src/plugins/session.rs @@ -198,7 +198,7 @@ mod tests { let expiry = Some(exp_curtime_odt); let issued_at = curtime_odt; let issued_by = IdentityId::User(tuuid); - let scope = AccessScope::IdentityOnly; + let scope = SessionScope::ReadOnly; let session = Value::Session( session_id, @@ -316,7 +316,7 @@ mod tests { let expiry = Some(exp_curtime_odt); let issued_at = curtime_odt; let issued_by = IdentityId::User(tuuid); - let scope = AccessScope::IdentityOnly; + let scope = SessionScope::ReadOnly; // Mod the user let modlist = modlist!([ @@ -453,7 +453,7 @@ mod tests { let pv_parent_id = PartialValue::Refer(parent); let issued_at = curtime_odt; let issued_by = IdentityId::User(tuuid); - let scope = AccessScope::IdentityOnly; + let scope = SessionScope::ReadOnly; // Mod the user let modlist = modlist!([ @@ -666,7 +666,7 @@ mod tests { let expiry = None; let issued_at = curtime_odt; let issued_by = IdentityId::User(tuuid); - let scope = AccessScope::IdentityOnly; + let scope = SessionScope::ReadOnly; let session = Value::Session( session_id, diff --git a/kanidmd/lib/src/repl/proto.rs b/kanidmd/lib/src/repl/proto.rs index e2cfe4de3..9c9ae7fa2 100644 --- a/kanidmd/lib/src/repl/proto.rs +++ b/kanidmd/lib/src/repl/proto.rs @@ -216,8 +216,16 @@ pub struct ReplOauth2SessionV1 { } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)] -pub enum ReplAccessScopeV1 { - IdentityOnly, +pub enum ReplSessionScopeV1 { + #[default] + ReadOnly, + ReadWrite, + PrivilegeCapable, + Synchronise, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +pub enum ReplApiTokenScopeV1 { #[default] ReadOnly, ReadWrite, @@ -239,7 +247,17 @@ pub struct ReplSessionV1 { pub issued_at: String, pub issued_by: ReplIdentityIdV1, pub cred_id: Uuid, - pub scope: ReplAccessScopeV1, + pub scope: ReplSessionScopeV1, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct ReplApiTokenV1 { + pub refer: Uuid, + pub label: String, + pub expiry: Option, + pub issued_at: String, + pub issued_by: ReplIdentityIdV1, + pub scope: ReplApiTokenScopeV1, } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] @@ -344,6 +362,9 @@ pub enum ReplAttrV1 { Session { set: Vec, }, + ApiToken { + set: Vec, + }, TotpSecret { set: Vec<(String, ReplTotpV1)>, }, diff --git a/kanidmd/lib/src/schema.rs b/kanidmd/lib/src/schema.rs index e79ba4c3b..95ae49b5a 100644 --- a/kanidmd/lib/src/schema.rs +++ b/kanidmd/lib/src/schema.rs @@ -205,6 +205,7 @@ impl SchemaAttribute { SyntaxType::DeviceKey => matches!(v, PartialValue::DeviceKey(_)), // Allow refer types. SyntaxType::Session => matches!(v, PartialValue::Refer(_)), + SyntaxType::ApiToken => matches!(v, PartialValue::Refer(_)), SyntaxType::Oauth2Session => matches!(v, PartialValue::Refer(_)), // These are just insensitive string lookups on the hex-ified kid. SyntaxType::JwsKeyEs256 => matches!(v, PartialValue::Iutf8(_)), @@ -255,6 +256,7 @@ impl SchemaAttribute { SyntaxType::Passkey => matches!(v, Value::Passkey(_, _, _)), SyntaxType::DeviceKey => matches!(v, Value::DeviceKey(_, _, _)), SyntaxType::Session => matches!(v, Value::Session(_, _)), + SyntaxType::ApiToken => matches!(v, Value::ApiToken(_, _)), SyntaxType::Oauth2Session => matches!(v, Value::Oauth2Session(_, _)), SyntaxType::JwsKeyEs256 => matches!(v, Value::JwsKeyEs256(_)), SyntaxType::JwsKeyRs256 => matches!(v, Value::JwsKeyRs256(_)), diff --git a/kanidmd/lib/src/server/access/create.rs b/kanidmd/lib/src/server/access/create.rs index 2ed02ac16..b69840f49 100644 --- a/kanidmd/lib/src/server/access/create.rs +++ b/kanidmd/lib/src/server/access/create.rs @@ -66,7 +66,7 @@ fn create_filter_entry<'a>( info!(event = %ident, "Access check for create event"); match ident.access_scope() { - AccessScope::IdentityOnly | AccessScope::ReadOnly | AccessScope::Synchronise => { + AccessScope::ReadOnly | AccessScope::Synchronise => { security_access!("denied ❌ - identity access scope is not permitted to create"); return IResult::Denied; } diff --git a/kanidmd/lib/src/server/access/delete.rs b/kanidmd/lib/src/server/access/delete.rs index 0dcbeecee..3eb02134b 100644 --- a/kanidmd/lib/src/server/access/delete.rs +++ b/kanidmd/lib/src/server/access/delete.rs @@ -65,7 +65,7 @@ fn delete_filter_entry<'a>( info!(event = %ident, "Access check for delete event"); match ident.access_scope() { - AccessScope::IdentityOnly | AccessScope::ReadOnly | AccessScope::Synchronise => { + AccessScope::ReadOnly | AccessScope::Synchronise => { security_access!("denied ❌ - identity access scope is not permitted to delete"); return IResult::Denied; } diff --git a/kanidmd/lib/src/server/access/mod.rs b/kanidmd/lib/src/server/access/mod.rs index 452d10bd5..8fc63081e 100644 --- a/kanidmd/lib/src/server/access/mod.rs +++ b/kanidmd/lib/src/server/access/mod.rs @@ -1629,17 +1629,9 @@ mod tests { let ev1 = unsafe { E_TESTPERSON_1.clone().into_sealed_committed() }; let ex_some = vec![Arc::new(ev1.clone())]; - let ex_none = vec![]; let r_set = vec![Arc::new(ev1)]; - let se_io = unsafe { - SearchEvent::new_impersonate_identity( - Identity::from_impersonate_entry_identityonly(E_TEST_ACCOUNT_1.clone()), - filter_all!(f_pres("name")), - ) - }; - let se_ro = unsafe { SearchEvent::new_impersonate_identity( Identity::from_impersonate_entry_readonly(E_TEST_ACCOUNT_1.clone()), @@ -1669,8 +1661,6 @@ mod tests { }; // Check the admin search event - test_acp_search!(&se_io, vec![acp.clone()], r_set.clone(), ex_none); - test_acp_search!(&se_ro, vec![acp.clone()], r_set.clone(), ex_some); test_acp_search!(&se_rw, vec![acp], r_set, ex_some); @@ -1687,14 +1677,6 @@ mod tests { let exv1 = unsafe { E_TESTPERSON_1_REDUCED.clone().into_sealed_committed() }; let ex_anon_some = vec![exv1]; - let ex_anon_none: Vec = vec![]; - - let se_anon_io = unsafe { - SearchEvent::new_impersonate_identity( - Identity::from_impersonate_entry_identityonly(E_TEST_ACCOUNT_1.clone()), - filter_all!(f_pres("name")), - ) - }; let se_anon_ro = unsafe { SearchEvent::new_impersonate_identity( @@ -1718,8 +1700,6 @@ mod tests { }; // Finally test it! - test_acp_search_reduce!(&se_anon_io, vec![acp.clone()], r_set.clone(), ex_anon_none); - test_acp_search_reduce!(&se_anon_ro, vec![acp], r_set, ex_anon_some); } @@ -1969,15 +1949,6 @@ mod tests { let ev1 = unsafe { E_TESTPERSON_1.clone().into_sealed_committed() }; let r_set = vec![Arc::new(ev1)]; - // Name present - let me_pres_io = unsafe { - ModifyEvent::new_impersonate_identity( - Identity::from_impersonate_entry_identityonly(E_TEST_ACCOUNT_1.clone()), - filter_all!(f_eq("name", PartialValue::new_iname("testperson1"))), - modlist!([m_pres("name", &Value::new_iname("value"))]), - ) - }; - // Name present let me_pres_ro = unsafe { ModifyEvent::new_impersonate_identity( @@ -2013,8 +1984,6 @@ mod tests { ) }; - test_acp_modify!(&me_pres_io, vec![acp_allow.clone()], &r_set, false); - test_acp_modify!(&me_pres_ro, vec![acp_allow.clone()], &r_set, false); test_acp_modify!(&me_pres_rw, vec![acp_allow], &r_set, true); @@ -2140,11 +2109,6 @@ mod tests { let admin = E_TEST_ACCOUNT_1.clone(); - let ce_admin_io = CreateEvent::new_impersonate_identity( - Identity::from_impersonate_entry_identityonly(admin.clone()), - vec![], - ); - let ce_admin_ro = CreateEvent::new_impersonate_identity( Identity::from_impersonate_entry_readonly(admin.clone()), vec![], @@ -2171,8 +2135,6 @@ mod tests { ) }; - test_acp_create!(&ce_admin_io, vec![acp.clone()], &r1_set, false); - test_acp_create!(&ce_admin_ro, vec![acp.clone()], &r1_set, false); test_acp_create!(&ce_admin_rw, vec![acp], &r1_set, true); @@ -2244,11 +2206,6 @@ mod tests { let admin = E_TEST_ACCOUNT_1.clone(); - let de_admin_io = DeleteEvent::new_impersonate_identity( - Identity::from_impersonate_entry_identityonly(admin.clone()), - filter_all!(f_eq("name", PartialValue::new_iname("testperson1"))), - ); - let de_admin_ro = DeleteEvent::new_impersonate_identity( Identity::from_impersonate_entry_readonly(admin.clone()), filter_all!(f_eq("name", PartialValue::new_iname("testperson1"))), @@ -2270,8 +2227,6 @@ mod tests { ) }; - test_acp_delete!(&de_admin_io, vec![acp.clone()], &r_set, false); - test_acp_delete!(&de_admin_ro, vec![acp.clone()], &r_set, false); test_acp_delete!(&de_admin_rw, vec![acp], &r_set, true); diff --git a/kanidmd/lib/src/server/access/modify.rs b/kanidmd/lib/src/server/access/modify.rs index 9bb545fcb..5cf562b7f 100644 --- a/kanidmd/lib/src/server/access/modify.rs +++ b/kanidmd/lib/src/server/access/modify.rs @@ -149,7 +149,7 @@ fn modify_ident_test<'a>(ident: &Identity) -> AccessResult<'a> { info!(event = %ident, "Access check for modify event"); match ident.access_scope() { - AccessScope::IdentityOnly | AccessScope::ReadOnly | AccessScope::Synchronise => { + AccessScope::ReadOnly | AccessScope::Synchronise => { security_access!("denied ❌ - identity access scope is not permitted to modify"); return AccessResult::Denied; } diff --git a/kanidmd/lib/src/server/access/search.rs b/kanidmd/lib/src/server/access/search.rs index 58487f411..9bfcae9db 100644 --- a/kanidmd/lib/src/server/access/search.rs +++ b/kanidmd/lib/src/server/access/search.rs @@ -82,7 +82,7 @@ fn search_filter_entry<'a>( info!(event = %ident, "Access check for search (filter) event"); match ident.access_scope() { - AccessScope::IdentityOnly | AccessScope::Synchronise => { + AccessScope::Synchronise => { security_access!("denied ❌ - identity access scope is not permitted to search"); return AccessResult::Denied; } diff --git a/kanidmd/lib/src/server/identity.rs b/kanidmd/lib/src/server/identity.rs index bbf9ace2e..8c858b239 100644 --- a/kanidmd/lib/src/server/identity.rs +++ b/kanidmd/lib/src/server/identity.rs @@ -9,7 +9,7 @@ use std::hash::Hash; use std::sync::Arc; use uuid::uuid; -use kanidm_proto::v1::{ApiTokenPurpose, UatPurpose, UatPurposeStatus}; +use kanidm_proto::v1::{ApiTokenPurpose, UatPurpose}; use serde::{Deserialize, Serialize}; @@ -17,7 +17,6 @@ use crate::prelude::*; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccessScope { - IdentityOnly, ReadOnly, ReadWrite, Synchronise, @@ -26,7 +25,6 @@ pub enum AccessScope { impl std::fmt::Display for AccessScope { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - AccessScope::IdentityOnly => write!(f, "identity only"), AccessScope::ReadOnly => write!(f, "read only"), AccessScope::ReadWrite => write!(f, "read write"), AccessScope::Synchronise => write!(f, "synchronise"), @@ -44,42 +42,15 @@ impl From<&ApiTokenPurpose> for AccessScope { } } -impl TryInto for AccessScope { - type Error = OperationError; - - fn try_into(self: AccessScope) -> Result { - match self { - AccessScope::ReadOnly => Ok(ApiTokenPurpose::ReadOnly), - AccessScope::ReadWrite => Ok(ApiTokenPurpose::ReadWrite), - AccessScope::Synchronise => Ok(ApiTokenPurpose::Synchronise), - AccessScope::IdentityOnly => Err(OperationError::InvalidEntryState), - } - } -} - impl From<&UatPurpose> for AccessScope { fn from(purpose: &UatPurpose) -> Self { match purpose { - UatPurpose::IdentityOnly => AccessScope::IdentityOnly, UatPurpose::ReadOnly => AccessScope::ReadOnly, UatPurpose::ReadWrite { .. } => AccessScope::ReadWrite, } } } -impl TryInto for AccessScope { - type Error = OperationError; - - fn try_into(self: AccessScope) -> Result { - match self { - AccessScope::ReadOnly => Ok(UatPurposeStatus::ReadOnly), - AccessScope::ReadWrite => Ok(UatPurposeStatus::ReadWrite), - AccessScope::IdentityOnly => Ok(UatPurposeStatus::IdentityOnly), - AccessScope::Synchronise => Err(OperationError::InvalidEntryState), - } - } -} - #[derive(Debug, Clone)] /// Metadata and the entry of the current Identity which is an external account/user. pub struct IdentUser { @@ -160,18 +131,6 @@ impl Identity { } } - #[cfg(test)] - pub fn from_impersonate_entry_identityonly( - entry: Arc>, - ) -> Self { - Identity { - origin: IdentType::User(IdentUser { entry }), - session_id: uuid!("00000000-0000-0000-0000-000000000000"), - scope: AccessScope::IdentityOnly, - limits: Limits::unlimited(), - } - } - #[cfg(test)] pub fn from_impersonate_entry_readonly(entry: Arc>) -> Self { Identity { diff --git a/kanidmd/lib/src/server/migrations.rs b/kanidmd/lib/src/server/migrations.rs index b8852c688..863927054 100644 --- a/kanidmd/lib/src/server/migrations.rs +++ b/kanidmd/lib/src/server/migrations.rs @@ -96,6 +96,10 @@ impl QueryServer { if system_info_version < 11 { migrate_txn.migrate_10_to_11()?; } + + if system_info_version < 12 { + migrate_txn.migrate_11_to_12()?; + } } migrate_txn.commit()?; @@ -332,6 +336,72 @@ impl<'a> QueryServerWriteTransaction<'a> { self.internal_batch_modify(modset.into_iter()) } + /// Migrate 11 to 12 + /// + /// Rewrite api-tokens from session to a dedicated api token type. + /// + #[instrument(level = "debug", skip_all)] + pub fn migrate_11_to_12(&mut self) -> Result<(), OperationError> { + admin_warn!("starting 11 to 12 migration."); + // sync_token_session + let filter = filter!(f_or!([ + f_pres("api_token_session"), + f_pres("sync_token_session"), + ])); + + let mut mod_candidates = self.internal_search_writeable(&filter).map_err(|e| { + admin_error!(err = ?e, "migrate_11_to_12 internal search failure"); + e + })?; + + // If there is nothing, we don't need to do anything. + if mod_candidates.is_empty() { + admin_info!("migrate_11_to_12 no entries to migrate, complete"); + return Ok(()); + } + + // First, filter based on if any credentials present actually are the legacy + // webauthn type. + + for (_, ent) in mod_candidates.iter_mut() { + if let Some(api_token_session) = ent.pop_ava("api_token_session") { + let api_token_session = api_token_session.migrate_session_to_apitoken() + .map_err(|e| { + error!("Failed to convert api_token_session from session -> apitoken"); + e + })?; + + ent.set_ava_set( + "api_token_session", + api_token_session); + } + + if let Some(sync_token_session) = ent.pop_ava("sync_token_session") { + let sync_token_session = sync_token_session.migrate_session_to_apitoken() + .map_err(|e| { + error!("Failed to convert sync_token_session from session -> apitoken"); + e + })?; + + ent.set_ava_set( + "sync_token_session", + sync_token_session); + } + }; + + let ( + pre_candidates, + candidates + ) = mod_candidates + .into_iter() + .unzip(); + + // Apply the batch mod. + self.internal_apply_writable( + pre_candidates, candidates + ) + } + #[instrument(level = "info", skip_all)] pub fn initialise_schema_core(&mut self) -> Result<(), OperationError> { admin_debug!("initialise_schema_core -> start ..."); diff --git a/kanidmd/lib/src/server/mod.rs b/kanidmd/lib/src/server/mod.rs index 4598b6723..cac76f8e3 100644 --- a/kanidmd/lib/src/server/mod.rs +++ b/kanidmd/lib/src/server/mod.rs @@ -497,6 +497,7 @@ pub trait QueryServerTransaction<'a> { SyntaxType::Passkey => Err(OperationError::InvalidAttribute("Passkey Values can not be supplied through modification".to_string())), SyntaxType::DeviceKey => Err(OperationError::InvalidAttribute("DeviceKey Values can not be supplied through modification".to_string())), SyntaxType::Session => Err(OperationError::InvalidAttribute("Session Values can not be supplied through modification".to_string())), + SyntaxType::ApiToken => Err(OperationError::InvalidAttribute("ApiToken Values can not be supplied through modification".to_string())), SyntaxType::JwsKeyEs256 => Err(OperationError::InvalidAttribute("JwsKeyEs256 Values can not be supplied through modification".to_string())), SyntaxType::JwsKeyRs256 => Err(OperationError::InvalidAttribute("JwsKeyRs256 Values can not be supplied through modification".to_string())), SyntaxType::Oauth2Session => Err(OperationError::InvalidAttribute("Oauth2Session Values can not be supplied through modification".to_string())), @@ -551,6 +552,7 @@ pub trait QueryServerTransaction<'a> { SyntaxType::ReferenceUuid | SyntaxType::OauthScopeMap | SyntaxType::Session + | SyntaxType::ApiToken | SyntaxType::Oauth2Session => { let un = self.name_to_uuid(value).unwrap_or(UUID_DOES_NOT_EXIST); Ok(PartialValue::Refer(un)) diff --git a/kanidmd/lib/src/value.rs b/kanidmd/lib/src/value.rs index 6ede2365b..4ff527bf2 100644 --- a/kanidmd/lib/src/value.rs +++ b/kanidmd/lib/src/value.rs @@ -3,6 +3,8 @@ //! typed values, allows their comparison, filtering and more. It also has the code for serialising //! these into a form for the backend that can be persistent into the [`Backend`](crate::be::Backend). +use crate::prelude::*; + use std::collections::BTreeSet; use std::convert::TryFrom; use std::fmt; @@ -11,7 +13,9 @@ use std::time::Duration; use compact_jwt::JwsSigner; use hashbrown::HashSet; +use kanidm_proto::v1::ApiTokenPurpose; use kanidm_proto::v1::Filter as ProtoFilter; +use kanidm_proto::v1::UatPurposeStatus; use kanidm_proto::v1::UiHint; use num_enum::TryFromPrimitive; use regex::Regex; @@ -25,7 +29,7 @@ use webauthn_rs::prelude::{DeviceKey as DeviceKeyV4, Passkey as PasskeyV4}; use crate::be::dbentry::DbIdentSpn; use crate::credential::{totp::Totp, Credential}; use crate::repl::cid::Cid; -use crate::server::identity::{AccessScope, IdentityId}; +use crate::server::identity::IdentityId; lazy_static! { pub static ref SPN_RE: Regex = { @@ -199,6 +203,7 @@ pub enum SyntaxType { Oauth2Session = 28, UiHint = 29, TotpSecret = 30, + ApiToken = 31, } impl TryFrom<&str> for SyntaxType { @@ -239,6 +244,7 @@ impl TryFrom<&str> for SyntaxType { "OAUTH2SESSION" => Ok(SyntaxType::Oauth2Session), "UIHINT" => Ok(SyntaxType::UiHint), "TOTPSECRET" => Ok(SyntaxType::TotpSecret), + "APITOKEN" => Ok(SyntaxType::ApiToken), _ => Err(()), } } @@ -278,6 +284,7 @@ impl fmt::Display for SyntaxType { SyntaxType::Oauth2Session => "OAUTH2SESSION", SyntaxType::UiHint => "UIHINT", SyntaxType::TotpSecret => "TOTPSECRET", + SyntaxType::ApiToken => "APITOKEN", }) } } @@ -327,7 +334,6 @@ pub enum PartialValue { Passkey(Uuid), DeviceKey(Uuid), TrustedDeviceEnrollment(Uuid), - Session(Uuid), // The label, if any. } @@ -698,7 +704,6 @@ impl PartialValue { PartialValue::PhoneNumber(a) => a.to_string(), PartialValue::IntentToken(u) => u.clone(), PartialValue::TrustedDeviceEnrollment(u) => u.as_hyphenated().to_string(), - PartialValue::Session(u) => u.as_hyphenated().to_string(), PartialValue::UiHint(u) => (*u as u16).to_string(), } } @@ -709,6 +714,56 @@ impl PartialValue { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApiTokenScope { + ReadOnly, + ReadWrite, + Synchronise, +} + +impl TryInto for ApiTokenScope { + type Error = OperationError; + + fn try_into(self: ApiTokenScope) -> Result { + match self { + ApiTokenScope::ReadOnly => Ok(ApiTokenPurpose::ReadOnly), + ApiTokenScope::ReadWrite => Ok(ApiTokenPurpose::ReadWrite), + ApiTokenScope::Synchronise => Ok(ApiTokenPurpose::Synchronise), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ApiToken { + pub label: String, + pub expiry: Option, + pub issued_at: OffsetDateTime, + pub issued_by: IdentityId, + pub scope: ApiTokenScope, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionScope { + ReadOnly, + ReadWrite, + PrivilegeCapable, + // For migration! To be removed in future! + Synchronise, +} + +impl TryInto for SessionScope { + type Error = OperationError; + + fn try_into(self: SessionScope) -> Result { + match self { + SessionScope::ReadOnly => Ok(UatPurposeStatus::ReadOnly), + SessionScope::ReadWrite => Ok(UatPurposeStatus::ReadWrite), + SessionScope::PrivilegeCapable => Ok(UatPurposeStatus::PrivilegeCapable), + SessionScope::Synchronise => Err(OperationError::InvalidEntryState), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Session { pub label: String, @@ -716,7 +771,7 @@ pub struct Session { pub issued_at: OffsetDateTime, pub issued_by: IdentityId, pub cred_id: Uuid, - pub scope: AccessScope, + pub scope: SessionScope, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -771,6 +826,7 @@ pub enum Value { TrustedDeviceEnrollment(Uuid), Session(Uuid, Session), + ApiToken(Uuid, ApiToken), Oauth2Session(Uuid, Oauth2Session), JwsKeyEs256(JwsSigner), diff --git a/kanidmd/lib/src/valueset/mod.rs b/kanidmd/lib/src/valueset/mod.rs index b183dad11..c3111619b 100644 --- a/kanidmd/lib/src/valueset/mod.rs +++ b/kanidmd/lib/src/valueset/mod.rs @@ -16,7 +16,7 @@ use crate::credential::{totp::Totp, Credential}; use crate::prelude::*; use crate::repl::{cid::Cid, proto::ReplAttrV1}; use crate::schema::SchemaAttribute; -use crate::value::{Address, IntentTokenState, Oauth2Session, Session}; +use crate::value::{Address, ApiToken, IntentTokenState, Oauth2Session, Session}; mod address; mod binary; @@ -59,7 +59,7 @@ pub use self::nsuniqueid::ValueSetNsUniqueId; pub use self::oauth::{ValueSetOauthScope, ValueSetOauthScopeMap}; pub use self::restricted::ValueSetRestricted; pub use self::secret::ValueSetSecret; -pub use self::session::{ValueSetOauth2Session, ValueSetSession}; +pub use self::session::{ValueSetApiToken, ValueSetOauth2Session, ValueSetSession}; pub use self::spn::ValueSetSpn; pub use self::ssh::ValueSetSshKey; pub use self::syntax::ValueSetSyntax; @@ -126,6 +126,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { Ok(None) } + fn migrate_session_to_apitoken(&self) -> Result { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + fn get_ssh_tag(&self, _tag: &str) -> Option<&str> { None } @@ -483,6 +488,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { None } + fn as_apitoken_map(&self) -> Option<&BTreeMap> { + debug_assert!(false); + None + } + fn as_oauth2session_map(&self) -> Option<&BTreeMap> { debug_assert!(false); None @@ -575,6 +585,7 @@ pub fn from_result_value_iter( | Value::TotpSecret(_, _) | Value::TrustedDeviceEnrollment(_) | Value::Session(_, _) + | Value::ApiToken(_, _) | Value::Oauth2Session(_, _) | Value::JwsKeyEs256(_) | Value::JwsKeyRs256(_) => { @@ -631,6 +642,7 @@ pub fn from_value_iter(mut iter: impl Iterator) -> Result ValueSetJwsKeyEs256::new(k), Value::JwsKeyRs256(k) => ValueSetJwsKeyRs256::new(k), Value::Session(u, m) => ValueSetSession::new(u, m), + Value::ApiToken(u, m) => ValueSetApiToken::new(u, m), Value::Oauth2Session(u, m) => ValueSetOauth2Session::new(u, m), Value::UiHint(u) => ValueSetUiHint::new(u), Value::TotpSecret(l, t) => ValueSetTotpSecret::new(l, t), @@ -677,6 +689,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result ValueSetPasskey::from_dbvs2(set), DbValueSetV2::DeviceKey(set) => ValueSetDeviceKey::from_dbvs2(set), DbValueSetV2::Session(set) => ValueSetSession::from_dbvs2(set), + DbValueSetV2::ApiToken(set) => ValueSetApiToken::from_dbvs2(set), DbValueSetV2::Oauth2Session(set) => ValueSetOauth2Session::from_dbvs2(set), DbValueSetV2::JwsKeyEs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set), DbValueSetV2::JwsKeyRs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set), @@ -726,6 +739,7 @@ pub fn from_repl_v1(rv1: &ReplAttrV1) -> Result { ReplAttrV1::OauthScopeMap { set } => ValueSetOauthScopeMap::from_repl_v1(set), ReplAttrV1::Oauth2Session { set } => ValueSetOauth2Session::from_repl_v1(set), ReplAttrV1::Session { set } => ValueSetSession::from_repl_v1(set), + ReplAttrV1::ApiToken { set } => ValueSetApiToken::from_repl_v1(set), ReplAttrV1::TotpSecret { set } => ValueSetTotpSecret::from_repl_v1(set), } } diff --git a/kanidmd/lib/src/valueset/session.rs b/kanidmd/lib/src/valueset/session.rs index 39de18d45..f47d369a7 100644 --- a/kanidmd/lib/src/valueset/session.rs +++ b/kanidmd/lib/src/valueset/session.rs @@ -4,14 +4,16 @@ use std::collections::{BTreeMap, BTreeSet}; use time::OffsetDateTime; use crate::be::dbvalue::{ - DbValueAccessScopeV1, DbValueIdentityId, DbValueOauth2Session, DbValueSession, + DbValueAccessScopeV1, DbValueApiToken, DbValueApiTokenScopeV1, DbValueIdentityId, + DbValueOauth2Session, DbValueSession, }; use crate::prelude::*; use crate::repl::proto::{ - ReplAccessScopeV1, ReplAttrV1, ReplIdentityIdV1, ReplOauth2SessionV1, ReplSessionV1, + ReplApiTokenScopeV1, ReplApiTokenV1, ReplAttrV1, ReplIdentityIdV1, ReplOauth2SessionV1, + ReplSessionScopeV1, ReplSessionV1, }; use crate::schema::SchemaAttribute; -use crate::value::{Oauth2Session, Session}; +use crate::value::{ApiToken, ApiTokenScope, Oauth2Session, Session, SessionScope}; use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet}; #[derive(Debug, Clone)] @@ -31,156 +33,162 @@ impl ValueSetSession { } pub fn from_dbvs2(data: Vec) -> Result { - let map = data - .into_iter() - .filter_map(|dbv| { - match dbv { - // MISTAKE - Skip due to lack of credential id - // Don't actually skip, generate a random cred id. Session cleanup will - // trim sessions on users, but if we skip blazenly we invalidate every api - // token ever issued. OPPS! - DbValueSession::V1 { - refer, - label, - expiry, - issued_at, - issued_by, - scope, - } => { - let cred_id = Uuid::new_v4(); + let map = + data.into_iter() + .filter_map(|dbv| { + match dbv { + // MISTAKE - Skip due to lack of credential id + // Don't actually skip, generate a random cred id. Session cleanup will + // trim sessions on users, but if we skip blazenly we invalidate every api + // token ever issued. OOPS! + DbValueSession::V1 { + refer, + label, + expiry, + issued_at, + issued_by, + scope, + } => { + let cred_id = Uuid::new_v4(); - // Convert things. - let issued_at = OffsetDateTime::parse(issued_at, time::Format::Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map_err(|e| { - admin_error!( + // Convert things. + let issued_at = OffsetDateTime::parse(issued_at, time::Format::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()?; + }) + .ok()?; - // This is a bit annoying. In the case we can't parse the optional - // expiry, we need to NOT return the session so that it's immediately - // invalidated. To do this we have to invert some of the options involved - // here. - let expiry = expiry - .map(|e_inner| { - OffsetDateTime::parse(e_inner, time::Format::Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - // We now have an - // Option> - }) - .transpose() - // Result, _> - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid expiry timestamp", - refer - ) - }) - // Option> - .ok()?; + // This is a bit annoying. In the case we can't parse the optional + // expiry, we need to NOT return the session so that it's immediately + // invalidated. To do this we have to invert some of the options involved + // here. + let expiry = expiry + .map(|e_inner| { + OffsetDateTime::parse(e_inner, time::Format::Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + // We now have an + // Option> + }) + .transpose() + // Result, _> + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid expiry timestamp", + refer + ) + }) + // Option> + .ok()?; - let issued_by = match issued_by { - DbValueIdentityId::V1Internal => IdentityId::Internal, - DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), - DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), - }; + let issued_by = match issued_by { + DbValueIdentityId::V1Internal => IdentityId::Internal, + DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), + DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), + }; - let scope = match scope { - DbValueAccessScopeV1::IdentityOnly => AccessScope::IdentityOnly, - DbValueAccessScopeV1::ReadOnly => AccessScope::ReadOnly, - DbValueAccessScopeV1::ReadWrite => AccessScope::ReadWrite, - DbValueAccessScopeV1::Synchronise => AccessScope::Synchronise, - }; + let scope = match scope { + DbValueAccessScopeV1::IdentityOnly + | DbValueAccessScopeV1::ReadOnly => SessionScope::ReadOnly, + DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite, + DbValueAccessScopeV1::PrivilegeCapable => { + SessionScope::PrivilegeCapable + } + DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise, + }; - Some(( + Some(( + refer, + Session { + label, + expiry, + issued_at, + issued_by, + cred_id, + scope, + }, + )) + } + DbValueSession::V2 { refer, - Session { - label, - expiry, - issued_at, - issued_by, - cred_id, - scope, - }, - )) - } - DbValueSession::V2 { - refer, - label, - expiry, - issued_at, - issued_by, - cred_id, - scope, - } => { - // Convert things. - let issued_at = OffsetDateTime::parse(issued_at, time::Format::Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map_err(|e| { - admin_error!( + label, + expiry, + issued_at, + issued_by, + cred_id, + scope, + } => { + // Convert things. + let issued_at = OffsetDateTime::parse(issued_at, time::Format::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()?; + }) + .ok()?; - // This is a bit annoying. In the case we can't parse the optional - // expiry, we need to NOT return the session so that it's immediately - // invalidated. To do this we have to invert some of the options involved - // here. - let expiry = expiry - .map(|e_inner| { - OffsetDateTime::parse(e_inner, time::Format::Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - // We now have an - // Option> - }) - .transpose() - // Result, _> - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid expiry timestamp", - refer - ) - }) - // Option> - .ok()?; + // This is a bit annoying. In the case we can't parse the optional + // expiry, we need to NOT return the session so that it's immediately + // invalidated. To do this we have to invert some of the options involved + // here. + let expiry = expiry + .map(|e_inner| { + OffsetDateTime::parse(e_inner, time::Format::Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + // We now have an + // Option> + }) + .transpose() + // Result, _> + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid expiry timestamp", + refer + ) + }) + // Option> + .ok()?; - let issued_by = match issued_by { - DbValueIdentityId::V1Internal => IdentityId::Internal, - DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), - DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), - }; + let issued_by = match issued_by { + DbValueIdentityId::V1Internal => IdentityId::Internal, + DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), + DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), + }; - let scope = match scope { - DbValueAccessScopeV1::IdentityOnly => AccessScope::IdentityOnly, - DbValueAccessScopeV1::ReadOnly => AccessScope::ReadOnly, - DbValueAccessScopeV1::ReadWrite => AccessScope::ReadWrite, - DbValueAccessScopeV1::Synchronise => AccessScope::Synchronise, - }; + let scope = match scope { + DbValueAccessScopeV1::IdentityOnly + | DbValueAccessScopeV1::ReadOnly => SessionScope::ReadOnly, + DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite, + DbValueAccessScopeV1::PrivilegeCapable => { + SessionScope::PrivilegeCapable + } + DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise, + }; - Some(( - refer, - Session { - label, - expiry, - issued_at, - issued_by, - cred_id, - scope, - }, - )) + Some(( + refer, + Session { + label, + expiry, + issued_at, + issued_by, + cred_id, + scope, + }, + )) + } } - } - }) - .collect(); + }) + .collect(); Ok(Box::new(ValueSetSession { map })) } @@ -240,10 +248,10 @@ impl ValueSetSession { }; let scope = match scope { - ReplAccessScopeV1::IdentityOnly => AccessScope::IdentityOnly, - ReplAccessScopeV1::ReadOnly => AccessScope::ReadOnly, - ReplAccessScopeV1::ReadWrite => AccessScope::ReadWrite, - ReplAccessScopeV1::Synchronise => AccessScope::Synchronise, + ReplSessionScopeV1::ReadOnly => SessionScope::ReadOnly, + ReplSessionScopeV1::ReadWrite => SessionScope::ReadWrite, + ReplSessionScopeV1::PrivilegeCapable => SessionScope::PrivilegeCapable, + ReplSessionScopeV1::Synchronise => SessionScope::Synchronise, }; Some(( @@ -365,10 +373,10 @@ impl ValueSetT for ValueSetSession { }, cred_id: m.cred_id, scope: match m.scope { - AccessScope::IdentityOnly => DbValueAccessScopeV1::IdentityOnly, - AccessScope::ReadOnly => DbValueAccessScopeV1::ReadOnly, - AccessScope::ReadWrite => DbValueAccessScopeV1::ReadWrite, - AccessScope::Synchronise => DbValueAccessScopeV1::Synchronise, + SessionScope::ReadOnly => DbValueAccessScopeV1::ReadOnly, + SessionScope::ReadWrite => DbValueAccessScopeV1::ReadWrite, + SessionScope::PrivilegeCapable => DbValueAccessScopeV1::PrivilegeCapable, + SessionScope::Synchronise => DbValueAccessScopeV1::Synchronise, }, }) .collect(), @@ -398,10 +406,10 @@ impl ValueSetT for ValueSetSession { }, cred_id: m.cred_id, scope: match m.scope { - AccessScope::IdentityOnly => ReplAccessScopeV1::IdentityOnly, - AccessScope::ReadOnly => ReplAccessScopeV1::ReadOnly, - AccessScope::ReadWrite => ReplAccessScopeV1::ReadWrite, - AccessScope::Synchronise => ReplAccessScopeV1::Synchronise, + SessionScope::ReadOnly => ReplSessionScopeV1::ReadOnly, + SessionScope::ReadWrite => ReplSessionScopeV1::ReadWrite, + SessionScope::PrivilegeCapable => ReplSessionScopeV1::PrivilegeCapable, + SessionScope::Synchronise => ReplSessionScopeV1::Synchronise, }, }) .collect(), @@ -442,6 +450,44 @@ impl ValueSetT for ValueSetSession { // This is what ties us as a type that can be refint checked. Some(Box::new(self.map.keys().copied())) } + + fn migrate_session_to_apitoken(&self) -> Result { + let map = self + .as_session_map() + .iter() + .map(|m| m.iter()) + .flatten() + .map( + |( + u, + Session { + label, + expiry, + issued_at, + issued_by, + cred_id: _, + scope, + }, + )| { + ( + *u, + ApiToken { + label: label.clone(), + expiry: expiry.clone(), + issued_at: issued_at.clone(), + issued_by: issued_by.clone(), + scope: match scope { + SessionScope::Synchronise => ApiTokenScope::Synchronise, + SessionScope::ReadWrite => ApiTokenScope::ReadWrite, + _ => ApiTokenScope::ReadOnly, + }, + }, + ) + }, + ) + .collect(); + Ok(Box::new(ValueSetApiToken { map })) + } } // == oauth2 session == @@ -803,3 +849,349 @@ impl ValueSetT for ValueSetOauth2Session { Some(Box::new(self.map.values().map(|m| &m.rs_uuid).copied())) } } + +#[derive(Debug, Clone)] +pub struct ValueSetApiToken { + map: BTreeMap, +} + +impl ValueSetApiToken { + pub fn new(u: Uuid, m: ApiToken) -> Box { + let mut map = BTreeMap::new(); + map.insert(u, m); + Box::new(ValueSetApiToken { map }) + } + + pub fn push(&mut self, u: Uuid, m: ApiToken) -> bool { + self.map.insert(u, m).is_none() + } + + pub fn from_dbvs2(data: Vec) -> Result { + let map = data + .into_iter() + .filter_map(|dbv| { + match dbv { + DbValueApiToken::V1 { + refer, + label, + expiry, + issued_at, + issued_by, + scope, + } => { + // Convert things. + let issued_at = OffsetDateTime::parse(issued_at, time::Format::Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + .map_err(|e| { + admin_error!( + ?e, + "Invalidating api token {} due to invalid issued_at timestamp", + refer + ) + }) + .ok()?; + + // This is a bit annoying. In the case we can't parse the optional + // expiry, we need to NOT return the session so that it's immediately + // invalidated. To do this we have to invert some of the options involved + // here. + let expiry = expiry + .map(|e_inner| { + OffsetDateTime::parse(e_inner, time::Format::Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + // We now have an + // Option> + }) + .transpose() + // Result, _> + .map_err(|e| { + admin_error!( + ?e, + "Invalidating api token {} due to invalid expiry timestamp", + refer + ) + }) + // Option> + .ok()?; + + let issued_by = match issued_by { + DbValueIdentityId::V1Internal => IdentityId::Internal, + DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), + DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), + }; + + let scope = match scope { + DbValueApiTokenScopeV1::ReadOnly => ApiTokenScope::ReadOnly, + DbValueApiTokenScopeV1::ReadWrite => ApiTokenScope::ReadWrite, + DbValueApiTokenScopeV1::Synchronise => ApiTokenScope::Synchronise, + }; + + Some(( + refer, + ApiToken { + label, + expiry, + issued_at, + issued_by, + scope, + }, + )) + } + } + }) + .collect(); + Ok(Box::new(ValueSetApiToken { map })) + } + + pub fn from_repl_v1(data: &[ReplApiTokenV1]) -> Result { + let map = data + .iter() + .filter_map( + |ReplApiTokenV1 { + refer, + label, + expiry, + issued_at, + issued_by, + scope, + }| { + // Convert things. + let issued_at = OffsetDateTime::parse(issued_at, time::Format::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()?; + + // This is a bit annoying. In the case we can't parse the optional + // expiry, we need to NOT return the session so that it's immediately + // invalidated. To do this we have to invert some of the options involved + // here. + let expiry = expiry + .as_ref() + .map(|e_inner| { + OffsetDateTime::parse(e_inner, time::Format::Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + // We now have an + // Option> + }) + .transpose() + // Result, _> + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid expiry timestamp", + refer + ) + }) + // Option> + .ok()?; + + let issued_by = match issued_by { + ReplIdentityIdV1::Internal => IdentityId::Internal, + ReplIdentityIdV1::Uuid(u) => IdentityId::User(*u), + ReplIdentityIdV1::Synch(u) => IdentityId::Synch(*u), + }; + + let scope = match scope { + ReplApiTokenScopeV1::ReadOnly => ApiTokenScope::ReadOnly, + ReplApiTokenScopeV1::ReadWrite => ApiTokenScope::ReadWrite, + ReplApiTokenScopeV1::Synchronise => ApiTokenScope::Synchronise, + }; + + Some(( + *refer, + ApiToken { + label: label.to_string(), + expiry, + issued_at, + issued_by, + scope, + }, + )) + }, + ) + .collect(); + Ok(Box::new(ValueSetApiToken { map })) + } + + // We need to allow this, because rust doesn't allow us to impl FromIterator on foreign + // types, and tuples are always foreign. + #[allow(clippy::should_implement_trait)] + pub fn from_iter(iter: T) -> Option> + where + T: IntoIterator, + { + let map = iter.into_iter().collect(); + Some(Box::new(ValueSetApiToken { map })) + } +} + +impl ValueSetT for ValueSetApiToken { + fn insert_checked(&mut self, value: Value) -> Result { + match value { + Value::ApiToken(u, m) => { + if let BTreeEntry::Vacant(e) = self.map.entry(u) { + e.insert(m); + Ok(true) + } else { + Ok(false) + } + } + _ => Err(OperationError::InvalidValueState), + } + } + + fn clear(&mut self) { + self.map.clear(); + } + + fn remove(&mut self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Refer(u) => self.map.remove(u).is_some(), + _ => false, + } + } + + fn contains(&self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Refer(u) => self.map.contains_key(u), + _ => false, + } + } + + fn substring(&self, _pv: &PartialValue) -> bool { + false + } + + fn lessthan(&self, _pv: &PartialValue) -> bool { + false + } + + fn len(&self) -> usize { + self.map.len() + } + + fn generate_idx_eq_keys(&self) -> Vec { + self.map + .keys() + .map(|u| u.as_hyphenated().to_string()) + .collect() + } + + fn syntax(&self) -> SyntaxType { + SyntaxType::ApiToken + } + + fn validate(&self, _schema_attr: &SchemaAttribute) -> bool { + true + } + + fn to_proto_string_clone_iter(&self) -> Box + '_> { + Box::new( + self.map + .iter() + .map(|(u, m)| format!("{}: {:?}", uuid_to_proto_string(*u), m)), + ) + } + + fn to_db_valueset_v2(&self) -> DbValueSetV2 { + DbValueSetV2::ApiToken( + self.map + .iter() + .map(|(u, m)| DbValueApiToken::V1 { + refer: *u, + label: m.label.clone(), + expiry: m.expiry.map(|odt| { + debug_assert!(odt.offset() == time::UtcOffset::UTC); + odt.format(time::Format::Rfc3339) + }), + issued_at: { + debug_assert!(m.issued_at.offset() == time::UtcOffset::UTC); + m.issued_at.format(time::Format::Rfc3339) + }, + issued_by: match m.issued_by { + IdentityId::Internal => DbValueIdentityId::V1Internal, + IdentityId::User(u) => DbValueIdentityId::V1Uuid(u), + IdentityId::Synch(u) => DbValueIdentityId::V1Sync(u), + }, + scope: match m.scope { + ApiTokenScope::ReadOnly => DbValueApiTokenScopeV1::ReadOnly, + ApiTokenScope::ReadWrite => DbValueApiTokenScopeV1::ReadWrite, + ApiTokenScope::Synchronise => DbValueApiTokenScopeV1::Synchronise, + }, + }) + .collect(), + ) + } + + fn to_repl_v1(&self) -> ReplAttrV1 { + ReplAttrV1::ApiToken { + set: self + .map + .iter() + .map(|(u, m)| ReplApiTokenV1 { + refer: *u, + label: m.label.clone(), + expiry: m.expiry.map(|odt| { + debug_assert!(odt.offset() == time::UtcOffset::UTC); + odt.format(time::Format::Rfc3339) + }), + issued_at: { + debug_assert!(m.issued_at.offset() == time::UtcOffset::UTC); + m.issued_at.format(time::Format::Rfc3339) + }, + issued_by: match m.issued_by { + IdentityId::Internal => ReplIdentityIdV1::Internal, + IdentityId::User(u) => ReplIdentityIdV1::Uuid(u), + IdentityId::Synch(u) => ReplIdentityIdV1::Synch(u), + }, + scope: match m.scope { + ApiTokenScope::ReadOnly => ReplApiTokenScopeV1::ReadOnly, + ApiTokenScope::ReadWrite => ReplApiTokenScopeV1::ReadWrite, + ApiTokenScope::Synchronise => ReplApiTokenScopeV1::Synchronise, + }, + }) + .collect(), + } + } + + fn to_partialvalue_iter(&self) -> Box + '_> { + Box::new(self.map.keys().cloned().map(PartialValue::Refer)) + } + + fn to_value_iter(&self) -> Box + '_> { + Box::new(self.map.iter().map(|(u, m)| Value::ApiToken(*u, m.clone()))) + } + + fn equal(&self, other: &ValueSet) -> bool { + if let Some(other) = other.as_apitoken_map() { + &self.map == other + } else { + debug_assert!(false); + false + } + } + + fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> { + if let Some(b) = other.as_apitoken_map() { + mergemaps!(self.map, b) + } else { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + } + + fn as_apitoken_map(&self) -> Option<&BTreeMap> { + Some(&self.map) + } + + fn as_ref_uuid_iter(&self) -> Option + '_>> { + // This is what ties us as a type that can be refint checked. + Some(Box::new(self.map.keys().copied())) + } +}