1use super::{AuthDoc, AuthTable, RoomAsk, SeatLot};
8use crate::error::{Error, Result};
9use crate::gate::{AccountGate, GateReason};
10use crate::model::{Principal, PublicKey, UnixMillis};
11use crate::sso::{ScimToken, SsoGroup, SsoProvider, SsoSource, SsoUser};
12
13#[derive(Clone, Debug)]
15pub struct SsoLogin {
16 pub account: String,
18 pub subject: String,
20 pub user_name: String,
24 pub display_name: String,
26 pub email: String,
28 pub email_verified: bool,
31 pub idp_subjects: Vec<String>,
35 pub candidates: Vec<Principal>,
39 pub room: RoomAsk,
42 pub session: Option<(String, UnixMillis)>,
45 pub now: UnixMillis,
47}
48
49#[derive(Clone, Debug)]
51pub struct SsoLoginDone {
52 pub principal: Principal,
54 pub user: SsoUser,
56 pub created: bool,
58 pub claimed: bool,
60 pub claimed_by: Option<&'static str>,
63 pub session: Option<PublicKey>,
65}
66
67#[derive(Clone, Debug)]
69pub struct SsoProvision {
70 pub account: String,
72 pub user_name: String,
74 pub display_name: String,
76 pub email: String,
78 pub external_id: String,
80 pub active: bool,
82 pub source: SsoSource,
84 pub candidates: Vec<Principal>,
86 pub room: RoomAsk,
88 pub actor: String,
90 pub now: UnixMillis,
92}
93
94#[derive(Clone, Debug, Default)]
96pub struct SsoUserPatch {
97 pub user_name: Option<String>,
99 pub display_name: Option<String>,
101 pub email: Option<String>,
103 pub external_id: Option<String>,
105 pub active: Option<bool>,
107}
108
109#[derive(Clone, Debug)]
111pub enum SsoImportOp {
112 Create(SsoProvision),
114 Update {
116 principal_id: String,
118 patch: SsoUserPatch,
120 },
121}
122
123#[derive(Clone, Debug)]
125pub struct SsoImportRow {
126 pub label: String,
128 pub op: SsoImportOp,
130}
131
132fn subject_index(account: &str, subject: &str) -> String {
133 format!("s\u{1f}{account}\u{1f}{subject}")
134}
135
136fn user_index(account: &str, user_name: &str) -> String {
137 format!("u\u{1f}{account}\u{1f}{user_name}")
138}
139
140fn refused(reason: impl Into<String>) -> Error {
141 Error::SsoRefused(reason.into())
142}
143
144fn rule_row(user: &SsoUser) -> nornir_identity_sso::rules::Row<'_> {
146 use nornir_identity_sso::rules::Source;
147 nornir_identity_sso::rules::Row {
148 external_id: &user.external_id,
149 email: &user.email,
150 user_name: &user.user_name,
151 source: match user.source {
152 SsoSource::Jit => Source::Jit,
153 SsoSource::Scim => Source::Scim,
154 SsoSource::Csv => Source::Csv,
155 },
156 bound: user.subject.is_some(),
157 rebind: user.rebind,
158 }
159}
160
161fn rule_login(login: &SsoLogin) -> nornir_identity_sso::rules::Login<'_> {
163 nornir_identity_sso::rules::Login {
164 email: &login.email,
165 email_verified: login.email_verified,
166 idp_ids: &login.idp_subjects,
167 }
168}
169
170fn refused_by(r: nornir_identity_sso::Refusal) -> Error {
174 use nornir_identity_sso::Refusal as R;
175 match r {
176 R::ClaimUnverified | R::ClaimNotAddress | R::ClaimDomain => refused(format!(
177 "{}. A user name (preferred_username) alone never claims; the account admin can bind the employee by \
178 setting its externalId to the person's id at the identity provider",
179 r.message()
180 )),
181 other => refused(other.message()),
182 }
183}
184
185impl AuthTable {
186 pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
188 let mut parts = key.splitn(3, '\u{1f}');
189 let _kind = parts.next();
190 parts.next() == Some(account)
191 }
192
193 pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
197 self.doc.load()?.sso_providers.get(account)
198 }
199
200 pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
202 self.doc.load()?.sso_providers.values()
203 }
204
205 pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
207 self.doc.load()?.sso_domains.get(domain)
208 }
209
210 pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
214 self.doc.update(|doc| {
215 if !doc.principals.contains_key(&provider.account)? {
216 return Err(Error::NoSuchPrincipal(provider.account.clone()));
217 }
218 if doc.employers.contains_key(&provider.account)? {
219 return Err(Error::InvalidInput {
220 what: "identity provider",
221 reason: format!(
222 "{} is an employee, not an account: only an account has an identity provider",
223 provider.account
224 ),
225 });
226 }
227 let mut provider = provider.clone();
231 let old = doc.sso_providers.get(&provider.account)?;
232 provider.verified_domains = old
233 .as_ref()
234 .map(|o| o.verified_domains.iter().filter(|d| provider.domains.contains(d)).cloned().collect())
235 .unwrap_or_default();
236 if let Some(old) = old {
237 for domain in old.verified_domains {
238 if doc.sso_domains.get(&domain)?.as_deref() == Some(provider.account.as_str()) {
239 doc.sso_domains.remove(&domain)?;
240 }
241 }
242 }
243 for domain in &provider.verified_domains {
244 doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
245 }
246 doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
247 Ok(provider)
248 })
249 }
250
251 pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
254 self.doc.update(|doc| {
255 let removed = doc.sso_providers.remove(account)?;
256 if let Some(provider) = &removed {
257 for domain in &provider.verified_domains {
259 if doc.sso_domains.get(domain)?.as_deref() == Some(account) {
260 doc.sso_domains.remove(domain)?;
261 }
262 }
263 }
264 Ok(removed)
265 })
266 }
267
268 pub fn verify_sso_domain(&self, account: &str, domain: &str) -> Result<SsoProvider> {
272 use nornir_identity_sso::Refusal;
273 self.doc.update(|doc| {
274 let mut provider = doc
275 .sso_providers
276 .get(account)?
277 .ok_or_else(|| refused(format!("{account} has no identity provider")))?;
278 if !provider.domains.iter().any(|d| d == domain) {
279 return Err(refused_by(Refusal::DomainUnknown));
280 }
281 match doc.sso_domains.get(domain)? {
282 Some(holder) if holder != account => return Err(refused_by(Refusal::DomainTaken)),
283 _ => {}
284 }
285 if !provider.verified_domains.iter().any(|d| d == domain) {
286 provider.verified_domains.push(domain.to_owned());
287 }
288 doc.sso_domains
289 .insert(domain.to_owned(), account.to_owned())?;
290 doc.sso_providers
291 .insert(account.to_owned(), provider.clone())?;
292 Ok(provider)
293 })
294 }
295
296 pub fn sso_user_rebind(
303 &self,
304 account: &str,
305 principal_ids: &[String],
306 external_id: Option<&str>,
307 ) -> Result<Vec<SsoUser>> {
308 if external_id.is_some() && principal_ids.len() != 1 {
309 return Err(Error::InvalidInput {
310 what: "rebind",
311 reason: "an externalId names exactly one employee".to_owned(),
312 });
313 }
314 self.doc.update(|doc| {
315 let targets: Vec<SsoUser> = if principal_ids.is_empty() {
316 doc.sso_users
317 .values()?
318 .into_iter()
319 .filter(|u| u.account == account && !u.deleted && u.subject.is_some())
320 .collect()
321 } else {
322 let mut rows = Vec::new();
323 for id in principal_ids {
324 let user = doc
325 .sso_users
326 .get(id)?
327 .filter(|u| u.account == account && !u.deleted)
328 .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
329 rows.push(user);
330 }
331 rows
332 };
333 let mut out = Vec::with_capacity(targets.len());
334 for mut user in targets {
335 if let Some(subject) = user.subject.take() {
336 if doc
337 .sso_index
338 .get(&subject_index(account, &subject))?
339 .as_deref()
340 == Some(user.principal_id.as_str())
341 {
342 doc.sso_index.remove(&subject_index(account, &subject))?;
343 }
344 }
345 user.rebind = true;
346 if let Some(id) = external_id {
347 user.external_id = id.trim().to_owned();
348 }
349 doc.sso_users
350 .insert(user.principal_id.clone(), user.clone())?;
351 out.push(user);
352 }
353 Ok(out)
354 })
355 }
356
357 pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
361 self.doc.load()?.sso_users.get(principal_id)
362 }
363
364 pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
366 let mut rows: Vec<SsoUser> = self
367 .doc
368 .load()?
369 .sso_users
370 .values()?
371 .into_iter()
372 .filter(|u| u.account == account)
373 .collect();
374 rows.sort_by(|a, b| {
375 a.user_name
376 .cmp(&b.user_name)
377 .then_with(|| a.principal_id.cmp(&b.principal_id))
378 });
379 Ok(rows)
380 }
381
382 pub fn sso_principal_for_subject(
384 &self,
385 account: &str,
386 subject: &str,
387 ) -> Result<Option<String>> {
388 self.doc
389 .load()?
390 .sso_index
391 .get(&subject_index(account, subject))
392 }
393
394 fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
397 for candidate in candidates {
398 if doc.principals.contains_key(&candidate.id)? {
399 continue;
400 }
401 if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
402 continue;
403 }
404 if Self::resolve_name(doc, &candidate.id)?.is_some() {
405 continue;
406 }
407 return Ok(Some(candidate.clone()));
408 }
409 Ok(None)
410 }
411
412 fn create_directory_employee(
416 doc: &mut AuthDoc,
417 account: &str,
418 candidates: &[Principal],
419 room: &RoomAsk,
420 user: SsoUser,
421 ) -> Result<(Principal, SsoUser)> {
422 let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
423 refused(format!(
424 "no free id for {:?} in {account}: every candidate is taken",
425 user.user_name
426 ))
427 })?;
428 let principal = Principal {
429 created: user.created,
430 ..principal
431 };
432 let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
433 let ask = RoomAsk {
434 from_seat: lots.iter().any(|lot| lot.open() > 0),
435 seats: 0,
436 dry_run: false,
437 order: String::new(),
438 ..room.clone()
439 };
440 let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
441 if plan.answer.shortfall_bytes > 0 {
442 return Err(Error::RoomShort {
443 account: account.to_owned(),
444 need: plan.answer.need_bytes,
445 unused: plan.answer.unused_bytes.unwrap_or(0),
446 short: plan.answer.shortfall_bytes,
447 });
448 }
449 let mut seats = lots;
450 Self::write_employee(
451 doc,
452 account,
453 &principal,
454 plan.lot,
455 "",
456 plan.answer.room_bytes,
457 &mut seats,
458 )?;
459 if !seats.is_empty() {
460 doc.seats.insert(account.to_owned(), seats)?;
461 }
462 let user = SsoUser {
463 principal_id: principal.id.clone(),
464 ..user
465 };
466 doc.sso_index
467 .insert(user_index(account, &user.user_name), principal.id.clone())?;
468 if let Some(subject) = &user.subject {
469 doc.sso_index
470 .insert(subject_index(account, subject), principal.id.clone())?;
471 }
472 doc.sso_users.insert(principal.id.clone(), user.clone())?;
473 Ok((principal, user))
474 }
475
476 pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
492 let session_key = match &login.session {
493 Some((openssh, expires)) => {
494 let parsed = super::parse_public_key(openssh)?;
495 let fingerprint = super::Fingerprint::of_key(&parsed);
496 Some(PublicKey {
497 fingerprint_sha256: fingerprint.as_str().to_owned(),
498 algo: parsed.algorithm().as_str().to_owned(),
499 principal_id: String::new(),
500 added: login.now,
501 last_used: None,
502 expires: Some(*expires),
503 openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
504 })
505 }
506 None => None,
507 };
508 let account = login.account.as_str();
509 self.doc.update(|doc| {
510 let provider = doc
511 .sso_providers
512 .get(account)?
513 .ok_or_else(|| refused(format!("{account} has no identity provider")))?;
514 let proven = provider.verified_domains.clone();
515 let mut created = false;
516 let mut claimed = false;
517 let mut claimed_by = None;
518 let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
519 let (principal, mut user) = match bound {
520 Some(id) => {
521 let user = doc
522 .sso_users
523 .get(&id)?
524 .filter(|u| u.account == account)
525 .ok_or_else(|| {
526 refused_by(nornir_identity_sso::Refusal::SubjectElsewhere)
527 })?;
528 let principal = doc
529 .principals
530 .get(&id)?
531 .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
532 (principal, user)
533 }
534 None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
535 Some(id) => {
536 let mut user = doc
537 .sso_users
538 .get(&id)?
539 .filter(|u| u.account == account && !u.deleted)
540 .ok_or_else(|| {
541 refused_by(nornir_identity_sso::Refusal::ClaimNotMember)
542 })?;
543 let basis = nornir_identity_sso::rules::may_claim(
546 &rule_row(&user),
547 &rule_login(login),
548 &proven,
549 )
550 .map_err(refused_by)?;
551 claimed_by = Some(basis.as_str());
552 user.rebind = false;
553 user.subject = Some(login.subject.clone());
554 doc.sso_index
555 .insert(subject_index(account, &login.subject), id.clone())?;
556 claimed = true;
557 let principal = doc
558 .principals
559 .get(&id)?
560 .ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
561 (principal, user)
562 }
563 None => {
564 nornir_identity_sso::rules::jit_allowed(
566 nornir_identity_sso::rules::JitPolicy::parse(&provider.jit),
567 &rule_login(login),
568 &proven,
569 )
570 .map_err(refused_by)?;
571 let user = SsoUser {
572 principal_id: String::new(),
573 account: account.to_owned(),
574 user_name: login.user_name.clone(),
575 display_name: login.display_name.clone(),
576 email: login.email.clone(),
577 external_id: String::new(),
578 subject: Some(login.subject.clone()),
579 active: true,
580 source: SsoSource::Jit,
581 created: login.now,
582 last_login: None,
583 sessions: Vec::new(),
584 deleted: false,
585 rebind: false,
586 };
587 created = true;
588 Self::create_directory_employee(
589 doc,
590 account,
591 &login.candidates,
592 &login.room,
593 user,
594 )?
595 }
596 },
597 };
598 if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
601 return Err(refused_by(nornir_identity_sso::Refusal::SubjectElsewhere));
602 }
603 if !user.active || user.deleted {
604 return Err(refused_by(nornir_identity_sso::Refusal::Deactivated));
605 }
606 if Self::gated(doc, &principal.id)? {
607 return Err(refused_by(nornir_identity_sso::Refusal::Locked));
608 }
609 if !created {
610 if !login.display_name.is_empty() {
611 user.display_name = login.display_name.clone();
612 }
613 if !login.email.is_empty() {
614 user.email = login.email.clone();
615 }
616 }
617 user.last_login = Some(login.now);
618 let mut enrolled = None;
619 if let Some(key) = &session_key {
620 if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
621 return Err(Error::KeyExists {
622 fingerprint: key.fingerprint_sha256.clone(),
623 principal_id: existing.principal_id,
624 });
625 }
626 let key = PublicKey {
627 principal_id: principal.id.clone(),
628 ..key.clone()
629 };
630 let mut live = Vec::with_capacity(user.sessions.len() + 1);
631 for held in std::mem::take(&mut user.sessions) {
632 if doc
633 .keys
634 .get(&held)?
635 .is_some_and(|k| k.is_valid_at(login.now))
636 {
637 live.push(held);
638 }
639 }
640 live.push(key.fingerprint_sha256.clone());
641 user.sessions = live;
642 doc.keys
643 .insert(key.fingerprint_sha256.clone(), key.clone())?;
644 enrolled = Some(key);
645 }
646 doc.sso_users.insert(principal.id.clone(), user.clone())?;
647 Ok(SsoLoginDone {
648 principal,
649 user,
650 created,
651 claimed,
652 claimed_by,
653 session: enrolled,
654 })
655 })
656 }
657
658 pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
663 self.doc.update(|doc| Self::provision_in(doc, provision))
664 }
665
666 fn provision_in(doc: &mut AuthDoc, provision: &SsoProvision) -> Result<SsoUser> {
668 let account = provision.account.as_str();
669 if !doc.principals.contains_key(account)? {
670 return Err(Error::NoSuchPrincipal(account.to_owned()));
671 }
672 if let Some(held) = doc
673 .sso_index
674 .get(&user_index(account, &provision.user_name))?
675 {
676 return Err(Error::NameExists {
677 name: provision.user_name.clone(),
678 principal_id: held,
679 });
680 }
681 let user = SsoUser {
682 principal_id: String::new(),
683 account: account.to_owned(),
684 user_name: provision.user_name.clone(),
685 display_name: provision.display_name.clone(),
686 email: provision.email.clone(),
687 external_id: provision.external_id.clone(),
688 subject: None,
689 active: true,
690 source: provision.source,
691 created: provision.now,
692 last_login: None,
693 sessions: Vec::new(),
694 deleted: false,
695 rebind: false,
696 };
697 let (_, mut user) = Self::create_directory_employee(
698 doc,
699 account,
700 &provision.candidates,
701 &provision.room,
702 user,
703 )?;
704 if !provision.active {
705 Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
706 doc.sso_users
707 .insert(user.principal_id.clone(), user.clone())?;
708 }
709 Ok(user)
710 }
711
712 pub fn sso_user_update(
718 &self,
719 account: &str,
720 principal_id: &str,
721 patch: &SsoUserPatch,
722 actor: &str,
723 now: UnixMillis,
724 ) -> Result<(SsoUser, bool)> {
725 self.doc
726 .update(|doc| Self::user_update_in(doc, account, principal_id, patch, actor, now))
727 }
728
729 fn user_update_in(
731 doc: &mut AuthDoc,
732 account: &str,
733 principal_id: &str,
734 patch: &SsoUserPatch,
735 actor: &str,
736 now: UnixMillis,
737 ) -> Result<(SsoUser, bool)> {
738 let mut user = doc
739 .sso_users
740 .get(principal_id)?
741 .filter(|u| u.account == account && !u.deleted)
742 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
743 if let Some(name) = &patch.user_name {
744 if *name != user.user_name {
745 if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
746 if held != user.principal_id {
747 return Err(Error::NameExists {
748 name: name.clone(),
749 principal_id: held,
750 });
751 }
752 }
753 doc.sso_index
754 .remove(&user_index(account, &user.user_name))?;
755 doc.sso_index
756 .insert(user_index(account, name), user.principal_id.clone())?;
757 user.user_name = name.clone();
758 }
759 }
760 if let Some(v) = &patch.display_name {
761 user.display_name = v.clone();
762 }
763 if let Some(v) = &patch.email {
764 user.email = v.clone();
765 }
766 if let Some(v) = &patch.external_id {
767 user.external_id = v.clone();
768 }
769 let mut changed = false;
770 match patch.active {
771 Some(false) if user.active => {
772 Self::deactivate(doc, &mut user, actor, now)?;
773 changed = true;
774 }
775 Some(true) if !user.active => {
776 user.active = true;
777 let holder = crate::sso::gate_holder(account);
778 if doc
779 .gates
780 .get(&user.principal_id)?
781 .is_some_and(|g| g.holder == holder)
782 {
783 doc.gates.remove(&user.principal_id)?;
784 }
785 changed = true;
786 }
787 _ => {}
788 }
789 doc.sso_users
790 .insert(user.principal_id.clone(), user.clone())?;
791 Ok((user, changed))
792 }
793
794 pub fn sso_import(
800 &self,
801 account: &str,
802 rows: &[SsoImportRow],
803 actor: &str,
804 now: UnixMillis,
805 commit: bool,
806 ) -> std::result::Result<(), (String, Error)> {
807 let mut at = String::new();
808 let mut apply = |doc: &mut AuthDoc| -> Result<()> {
809 for row in rows {
810 at.clone_from(&row.label);
811 match &row.op {
812 SsoImportOp::Create(provision) => {
813 if provision.account != account {
814 return Err(Error::NoSuchPrincipal(provision.account.clone()));
815 }
816 Self::provision_in(doc, provision)?;
817 }
818 SsoImportOp::Update {
819 principal_id,
820 patch,
821 } => {
822 Self::user_update_in(doc, account, principal_id, patch, actor, now)?;
823 }
824 }
825 }
826 at.clear();
827 Ok(())
828 };
829 let result = if commit {
830 self.doc.update(&mut apply)
831 } else {
832 match self.doc.load() {
833 Ok(current) => {
834 let mut copy = (*current).clone();
835 apply(&mut copy)
836 }
837 Err(e) => Err(e),
838 }
839 };
840 result.map_err(|e| (at, e))
841 }
842
843 pub fn sso_user_delete(
848 &self,
849 account: &str,
850 principal_id: &str,
851 actor: &str,
852 now: UnixMillis,
853 ) -> Result<SsoUser> {
854 self.doc.update(|doc| {
855 let mut user = doc
856 .sso_users
857 .get(principal_id)?
858 .filter(|u| u.account == account && !u.deleted)
859 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
860 if user.active {
861 Self::deactivate(doc, &mut user, actor, now)?;
862 }
863 user.deleted = true;
864 doc.sso_index
865 .remove(&user_index(account, &user.user_name))?;
866 for (gid, mut group) in doc.sso_groups.entries()? {
867 if group.account == account && group.members.contains(&user.principal_id) {
868 group.members.retain(|m| *m != user.principal_id);
869 doc.sso_groups.insert(gid, group)?;
870 }
871 }
872 doc.sso_users
873 .insert(user.principal_id.clone(), user.clone())?;
874 Ok(user)
875 })
876 }
877
878 fn deactivate(
882 doc: &mut AuthDoc,
883 user: &mut SsoUser,
884 actor: &str,
885 now: UnixMillis,
886 ) -> Result<()> {
887 user.active = false;
888 let id = user.principal_id.clone();
889 let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
890 for (cid, mut credential) in doc.credentials.entries()? {
891 if credential.principal_id == id && !credential.sessions.is_empty() {
892 ended.append(&mut credential.sessions);
893 doc.credentials.insert(cid, credential)?;
894 }
895 }
896 for fingerprint in ended {
897 if doc
898 .keys
899 .get(&fingerprint)?
900 .is_some_and(|k| k.principal_id == id)
901 {
902 doc.keys.remove(&fingerprint)?;
903 }
904 }
905 let holder = crate::sso::gate_holder(&user.account);
906 let held_by_another = doc
907 .gates
908 .get(&id)?
909 .is_some_and(|g| g.holder != holder && g.is_live_at(now));
910 if !held_by_another {
911 doc.gates.insert(
912 id.clone(),
913 AccountGate {
914 account: id.clone(),
915 reason: GateReason::Administrative,
916 holder,
917 actor: actor.to_owned(),
918 note: format!("deactivated in {}'s directory", user.account),
919 at: now,
920 expires: None,
921 },
922 )?;
923 }
924 Ok(())
925 }
926
927 pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
931 self.doc.update(|doc| {
932 if !doc.principals.contains_key(&token.account)? {
933 return Err(Error::NoSuchPrincipal(token.account.clone()));
934 }
935 if doc.scim_tokens.contains_key(&token.secret_hash)? {
936 return Err(Error::TokenExists {
937 hash: token.secret_hash.clone(),
938 principal_id: token.account.clone(),
939 });
940 }
941 doc.scim_tokens
942 .insert(token.secret_hash.clone(), token.clone())?;
943 Ok(token.clone())
944 })
945 }
946
947 pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
949 self.doc.load()?.scim_tokens.get(hash)
950 }
951
952 pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
954 let mut rows: Vec<ScimToken> = self
955 .doc
956 .load()?
957 .scim_tokens
958 .values()?
959 .into_iter()
960 .filter(|t| t.account == account)
961 .collect();
962 rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
963 Ok(rows)
964 }
965
966 pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
968 self.doc.update(|doc| {
969 let hit = doc
970 .scim_tokens
971 .values()?
972 .into_iter()
973 .find(|t| t.account == account && t.id == id);
974 match hit {
975 Some(token) => doc.scim_tokens.remove(&token.secret_hash),
976 None => Ok(None),
977 }
978 })
979 }
980
981 pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
983 let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
984 t.last_used
985 .is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
986 });
987 if !stale {
988 return Ok(());
989 }
990 self.doc.update(|doc| {
991 doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
992 Ok(())
993 })
994 }
995
996 pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
1000 let mut rows: Vec<SsoGroup> = self
1001 .doc
1002 .load()?
1003 .sso_groups
1004 .values()?
1005 .into_iter()
1006 .filter(|g| g.account == account)
1007 .collect();
1008 rows.sort_by(|a, b| {
1009 a.display_name
1010 .cmp(&b.display_name)
1011 .then_with(|| a.id.cmp(&b.id))
1012 });
1013 Ok(rows)
1014 }
1015
1016 pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
1019 self.doc.update(|doc| {
1020 for member in &group.members {
1021 let ok = doc
1022 .sso_users
1023 .get(member)?
1024 .is_some_and(|u| u.account == group.account && !u.deleted);
1025 if !ok {
1026 return Err(Error::InvalidInput {
1027 what: "group",
1028 reason: format!("{member} is not a user of {}'s directory", group.account),
1029 });
1030 }
1031 }
1032 for other in doc.sso_groups.values()? {
1033 if other.account == group.account
1034 && other.id != group.id
1035 && other.display_name.eq_ignore_ascii_case(&group.display_name)
1036 {
1037 return Err(Error::NameExists {
1038 name: group.display_name.clone(),
1039 principal_id: other.id,
1040 });
1041 }
1042 }
1043 if let Some(old) = doc.sso_groups.get(&group.id)? {
1044 if old.account != group.account {
1045 return Err(Error::InvalidInput {
1046 what: "group",
1047 reason: "the group is another account's".to_owned(),
1048 });
1049 }
1050 }
1051 doc.sso_groups.insert(group.id.clone(), group.clone())?;
1052 Ok(group.clone())
1053 })
1054 }
1055
1056 pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
1058 self.doc.update(|doc| match doc.sso_groups.get(id)? {
1059 Some(group) if group.account == account => doc.sso_groups.remove(id),
1060 _ => Ok(None),
1061 })
1062 }
1063
1064 pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
1067 let doc = self.doc.load()?;
1068 let Some(user) = doc.sso_users.get(principal_id)? else {
1069 return Ok(0);
1070 };
1071 let mut n = 0;
1072 for fp in &user.sessions {
1073 if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
1074 n += 1;
1075 }
1076 }
1077 Ok(n)
1078 }
1079}
1080
1081#[cfg(test)]
1082mod older_reader_tests {
1083 use std::collections::BTreeSet;
1084 use std::sync::Arc;
1085
1086 use super::super::{AuthTable, AUTH_REF};
1087 use crate::control::ControlStore;
1088 use crate::model::{Principal, PrincipalKind};
1089 use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};
1090
1091 const OLDER: &[&str] = &[
1097 "principals",
1098 "keys",
1099 "grants",
1100 "tokens",
1101 "memberships",
1102 "bootstrap",
1103 "gates",
1104 "names",
1105 "employers",
1106 "rooms",
1107 "seats",
1108 "room_orders",
1109 "credentials",
1110 "invites",
1111 ];
1112
1113 fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
1114 let id = store
1115 .read_doc(AUTH_REF)
1116 .unwrap()
1117 .expect("the table was written");
1118 let bytes = store.get_blob(&id).unwrap().expect("its blob");
1119 let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1120 doc.as_object()
1121 .expect("an object")
1122 .keys()
1123 .cloned()
1124 .collect()
1125 }
1126
1127 fn provider(account: &str) -> SsoProvider {
1128 SsoProvider {
1129 account: account.to_owned(),
1130 protocol: SsoProtocol::Oidc,
1131 display_name: format!("{account} corp"),
1132 domains: vec![format!("{account}.example")],
1133 oidc: Some(OidcSettings {
1134 issuer: "https://idp.example.test".into(),
1135 client_id: "c".into(),
1136 ..Default::default()
1137 }),
1138 saml: None,
1139 require_sso: false,
1140 require_passkey: false,
1141 console_origin: "https://gunnar.example".into(),
1142 updated: 1,
1143 updated_by: account.to_owned(),
1144 verified_domains: Vec::new(),
1145 jit: String::new(),
1146 }
1147 }
1148
1149 #[test]
1154 fn sso_maps_are_written_only_when_used() {
1155 let store = crate::memory_control_store().expect("store");
1156 let auth = AuthTable::new(Arc::clone(&store)).expect("table");
1157 auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
1158 .expect("account");
1159 let keys = stored_keys(&store);
1160 let unknown: Vec<&String> = keys
1161 .iter()
1162 .filter(|k| !OLDER.contains(&k.as_str()))
1163 .collect();
1164 assert!(
1165 unknown.is_empty(),
1166 "a table without SSO carries keys an older gunnar refuses: {unknown:?}"
1167 );
1168
1169 auth.set_sso_provider(provider("acme")).expect("provider");
1170 auth.verify_sso_domain("acme", "acme.example")
1172 .expect("proven");
1173 let keys = stored_keys(&store);
1174 assert!(
1175 keys.contains("sso_providers") && keys.contains("sso_domains"),
1176 "a provider is written: {keys:?}"
1177 );
1178
1179 auth.remove_sso_provider("acme").expect("removed");
1180 let keys = stored_keys(&store);
1181 let unknown: Vec<&String> = keys
1182 .iter()
1183 .filter(|k| !OLDER.contains(&k.as_str()))
1184 .collect();
1185 assert!(
1186 unknown.is_empty(),
1187 "the provider is gone, its maps must be too: {unknown:?}"
1188 );
1189 }
1190}