diff --git a/kanidm_book/src/accounts_and_groups.md b/kanidm_book/src/accounts_and_groups.md index e8bc17c4f..227252f24 100644 --- a/kanidm_book/src/accounts_and_groups.md +++ b/kanidm_book/src/accounts_and_groups.md @@ -117,6 +117,30 @@ where the "valid from" is *after* the expire_at, the expire_at will be respected These validity settings impact all authentication functions of the account (kanidm, ldap, radius). +## People Accounts + +Kanidm allows extending accounts to include additional "people" attributes, +such as their legal name and email address. + +Initially, an account does not have these attributes. If desired, an account +may be modified to have these "person" attributes like so: + + # Note, both the --legalname and --mail flags may be omitted + kanidm account person extend demo_user --legalname "initial name" --mail "initial@email.address" + +Once an account has been extended, the "person" attributes may be set by the +user of the account, or anyone with enough privileges. + +Whether an account is currently a "person" or not can be identified from the "account get" output: + + kanidm account get demo_user + # --- + # class: person + # ... (other output omitted) + +The presence of a "class: person" stanza indicates that this account may have +"people" attributes. + ## Why Can't I Change admin With idm_admin? As a security mechanism there is a distinction between "accounts" and "high permission diff --git a/kanidm_client/src/asynchronous.rs b/kanidm_client/src/asynchronous.rs index b2907cb5e..503aea886 100644 --- a/kanidm_client/src/asynchronous.rs +++ b/kanidm_client/src/asynchronous.rs @@ -1314,7 +1314,7 @@ impl KanidmAsyncClient { mail: Option<&[String]>, legalname: Option<&str>, ) -> Result<(), ClientError> { - let px = AccountPersonExtend { + let px = AccountPersonSet { mail: mail.map(|s| s.to_vec()), legalname: legalname.map(str::to_string), }; @@ -1322,6 +1322,20 @@ impl KanidmAsyncClient { .await } + pub async fn idm_account_person_set( + &self, + id: &str, + mail: Option<&[String]>, + legalname: Option<&str>, + ) -> Result<(), ClientError> { + let px = AccountPersonSet { + mail: mail.map(|s| s.to_vec()), + legalname: legalname.map(str::to_string), + }; + self.perform_post_request(format!("/v1/account/{}/_person/_set", id).as_str(), px) + .await + } + pub async fn idm_account_get_ssh_pubkey( &self, id: &str, diff --git a/kanidm_client/src/lib.rs b/kanidm_client/src/lib.rs index ab803f3b3..ea3221fe4 100644 --- a/kanidm_client/src/lib.rs +++ b/kanidm_client/src/lib.rs @@ -850,6 +850,15 @@ impl KanidmClient { tokio_block_on(self.asclient.idm_account_person_extend(id, mail, legalname)) } + pub fn idm_account_person_set( + &self, + id: &str, + mail: Option<&[String]>, + legalname: Option<&str>, + ) -> Result<(), ClientError> { + tokio_block_on(self.asclient.idm_account_person_set(id, mail, legalname)) + } + /* pub fn idm_account_rename_ssh_pubkey(&self, id: &str, oldtag: &str, newtag: &str) -> Result<(), ClientError> { self.perform_put_request(format!("/v1/account/{}/_ssh_pubkeys/{}", id, oldtag).as_str(), newtag.to_string()) diff --git a/kanidm_client/tests/default_entries.rs b/kanidm_client/tests/default_entries.rs index f13a50a2c..75fa23c17 100644 --- a/kanidm_client/tests/default_entries.rs +++ b/kanidm_client/tests/default_entries.rs @@ -95,9 +95,18 @@ fn is_attr_writable(rsclient: &KanidmClient, id: &str, attr: &str) -> Option Some( + rsclient + .idm_account_person_set(id, None, Some("test legal name".into())) + .is_ok(), + ), + "mail" => Some( + rsclient + .idm_account_person_set(id, Some(&[format!("{}@example.com", id)]), None) + .is_ok(), + ), entry => { let new_value = match entry { - "mail" => format!("{}@example.com", id), "acp_receiver" => r#"{"eq":["memberof","00000000-0000-0000-0000-000000000011"]}"#.to_string(), "acp_targetscope" => "{\"and\": [{\"eq\": [\"class\",\"access_control_profile\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}".to_string(), _ => id.to_string(), diff --git a/kanidm_proto/src/v1.rs b/kanidm_proto/src/v1.rs index 5fd31d381..fc8ee5bb9 100644 --- a/kanidm_proto/src/v1.rs +++ b/kanidm_proto/src/v1.rs @@ -322,7 +322,7 @@ pub struct AccountUnixExtend { } #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AccountPersonExtend { +pub struct AccountPersonSet { pub mail: Option>, pub legalname: Option, } diff --git a/kanidm_tools/src/cli/account.rs b/kanidm_tools/src/cli/account.rs index 77a19a655..3ef755bf2 100644 --- a/kanidm_tools/src/cli/account.rs +++ b/kanidm_tools/src/cli/account.rs @@ -39,6 +39,7 @@ impl AccountOpt { AccountPosix::SetPassword(apo) => apo.copt.debug, }, AccountOpt::Person(apopt) => match apopt { + AccountPerson::Extend(apo) => apo.copt.debug, AccountPerson::Set(apo) => apo.copt.debug, }, AccountOpt::Ssh(asopt) => match asopt { @@ -396,7 +397,7 @@ impl AccountOpt { } }, // end AccountOpt::Posix AccountOpt::Person(apopt) => match apopt { - AccountPerson::Set(aopt) => { + AccountPerson::Extend(aopt) => { let client = aopt.copt.to_client(); if let Err(e) = client.idm_account_person_extend( aopt.aopts.account_id.as_str(), @@ -406,6 +407,16 @@ impl AccountOpt { error!("Error -> {:?}", e); } } + AccountPerson::Set(aopt) => { + let client = aopt.copt.to_client(); + if let Err(e) = client.idm_account_person_set( + aopt.aopts.account_id.as_str(), + aopt.mail.as_deref(), + aopt.legalname.as_deref(), + ) { + error!("Error -> {:?}", e); + } + } }, // end AccountOpt::Person AccountOpt::Ssh(asopt) => match asopt { AccountSsh::List(aopt) => { diff --git a/kanidm_tools/src/opt/kanidm.rs b/kanidm_tools/src/opt/kanidm.rs index e8ef0b153..13aae4ae8 100644 --- a/kanidm_tools/src/opt/kanidm.rs +++ b/kanidm_tools/src/opt/kanidm.rs @@ -233,6 +233,8 @@ pub struct AccountPersonOpt { #[derive(Debug, StructOpt)] pub enum AccountPerson { + #[structopt(name = "extend")] + Extend(AccountPersonOpt), #[structopt(name = "set")] Set(AccountPersonOpt), } diff --git a/kanidmd/score/src/https/mod.rs b/kanidmd/score/src/https/mod.rs index f0a0eed1c..1d5d58a73 100644 --- a/kanidmd/score/src/https/mod.rs +++ b/kanidmd/score/src/https/mod.rs @@ -524,6 +524,9 @@ pub fn create_https_server( account_route .at("/:id/_person/_extend") .post(account_post_id_person_extend); + account_route + .at("/:id/_person/_set") + .post(account_post_id_person_set); account_route.at("/:id/_lock").get(do_nothing); account_route.at("/:id/_credential").get(do_nothing); diff --git a/kanidmd/score/src/https/v1.rs b/kanidmd/score/src/https/v1.rs index e5eab99b7..503cca1d1 100644 --- a/kanidmd/score/src/https/v1.rs +++ b/kanidmd/score/src/https/v1.rs @@ -6,7 +6,7 @@ use kanidm::status::StatusRequestEvent; use kanidm_proto::v1::Entry as ProtoEntry; use kanidm_proto::v1::{ - AccountPersonExtend, AccountUnixExtend, AuthRequest, AuthResponse, AuthState as ProtoAuthState, + AccountPersonSet, AccountUnixExtend, AuthRequest, AuthResponse, AuthState as ProtoAuthState, CreateRequest, DeleteRequest, GroupUnixExtend, ModifyRequest, OperationError, SearchRequest, SetCredentialRequest, SingleStringRequest, }; @@ -556,7 +556,7 @@ pub async fn account_get_id_radius_token(req: tide::Request) -> tide:: pub async fn account_post_id_person_extend(mut req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; - let obj: AccountPersonExtend = req.body_json().await?; + let obj: AccountPersonSet = req.body_json().await?; let (eventid, hvalue) = req.new_eventid(); let res = req .state() @@ -566,6 +566,19 @@ pub async fn account_post_id_person_extend(mut req: tide::Request) -> to_tide_response(res, hvalue) } +pub async fn account_post_id_person_set(mut req: tide::Request) -> tide::Result { + let uat = req.get_current_uat(); + let uuid_or_name = req.get_url_param("id")?; + let obj: AccountPersonSet = req.body_json().await?; + let (eventid, hvalue) = req.new_eventid(); + let res = req + .state() + .qe_w_ref + .handle_idmaccountpersonset(uat, uuid_or_name, obj, eventid) + .await; + to_tide_response(res, hvalue) +} + pub async fn account_post_id_unix(mut req: tide::Request) -> tide::Result { let uat = req.get_current_uat(); let uuid_or_name = req.get_url_param("id")?; diff --git a/kanidmd/src/lib/actors/v1_write.rs b/kanidmd/src/lib/actors/v1_write.rs index 9deb5b991..c8ebf3466 100644 --- a/kanidmd/src/lib/actors/v1_write.rs +++ b/kanidmd/src/lib/actors/v1_write.rs @@ -28,7 +28,7 @@ use kanidm_proto::v1::Entry as ProtoEntry; use kanidm_proto::v1::Modify as ProtoModify; use kanidm_proto::v1::ModifyList as ProtoModifyList; use kanidm_proto::v1::{ - AccountPersonExtend, AccountUnixExtend, CreateRequest, DeleteRequest, GroupUnixExtend, + AccountPersonSet, AccountUnixExtend, CreateRequest, DeleteRequest, GroupUnixExtend, ModifyRequest, SetCredentialRequest, SetCredentialResponse, }; @@ -963,13 +963,11 @@ impl QueryServerWriteV1 { &self, uat: Option, uuid_or_name: String, - px: AccountPersonExtend, + px: AccountPersonSet, eventid: Uuid, ) -> Result<(), OperationError> { - let AccountPersonExtend { mail, legalname } = px; + let AccountPersonSet { mail, legalname } = px; - // The filter_map here means we only create the mods if the gidnumber or shell are set - // in the actual request. let mut mods: Vec<_> = Vec::with_capacity(4 + mail.as_ref().map(|v| v.len()).unwrap_or(0)); mods.push(Modify::Present("class".into(), Value::new_class("person"))); @@ -1011,6 +1009,61 @@ impl QueryServerWriteV1 { res } + #[instrument( + level = "trace", + name = "idmaccountpersonset", + skip(self, uat, uuid_or_name, eventid) + fields(uuid = ?eventid) + )] + pub async fn handle_idmaccountpersonset( + &self, + uat: Option, + uuid_or_name: String, + px: AccountPersonSet, + eventid: Uuid, + ) -> Result<(), OperationError> { + let AccountPersonSet { mail, legalname } = px; + + let mut mods: Vec<_> = Vec::with_capacity(3 + mail.as_ref().map(|v| v.len()).unwrap_or(0)); + + if let Some(s) = legalname { + mods.push(Modify::Purged("legalname".into())); + mods.push(Modify::Present("legalname".into(), Value::new_utf8(s))); + } + + if let Some(mail) = mail { + mods.push(Modify::Purged("mail".into())); + + let mut miter = mail.into_iter(); + if let Some(m_primary) = miter.next() { + let v = + Value::new_email_address_primary_s(m_primary.as_str()).ok_or_else(|| { + OperationError::InvalidAttribute(format!( + "Invalid mail address {}", + m_primary + )) + })?; + mods.push(Modify::Present("mail".into(), v)); + } + + for m in miter { + let v = Value::new_email_address_s(m.as_str()).ok_or_else(|| { + OperationError::InvalidAttribute(format!("Invalid mail address {}", m)) + })?; + mods.push(Modify::Present("mail".into(), v)); + } + } + + let ml = ModifyList::new_list(mods); + + let filter = filter_all!(f_eq("class", PartialValue::new_class("account"))); + + let res = self + .modify_from_internal_parts(uat, &uuid_or_name, &ml, filter) + .await; + res + } + #[instrument( level = "trace", name = "idmaccountunixextend",