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
109fn subject_index(account: &str, subject: &str) -> String {
110 format!("s\u{1f}{account}\u{1f}{subject}")
111}
112
113fn user_index(account: &str, user_name: &str) -> String {
114 format!("u\u{1f}{account}\u{1f}{user_name}")
115}
116
117fn refused(reason: impl Into<String>) -> Error {
118 Error::SsoRefused(reason.into())
119}
120
121fn claim_basis(user: &SsoUser, login: &SsoLogin, domains: &[String]) -> Result<&'static str> {
139 let external_id = user.external_id.trim();
140 if !external_id.is_empty() && login.idp_subjects.iter().any(|s| s.trim() == external_id) {
141 return Ok("external_id");
142 }
143 let email = login.email.trim().to_ascii_lowercase();
144 let row = [user.email.trim().to_ascii_lowercase(), user.user_name.trim().to_ascii_lowercase()];
145 if !login.email_verified || email.is_empty() {
146 return Err(claim_refused(
147 "sso-claim-unverified",
148 "the identity provider did not assert a verified e-mail (email_verified), and the \
149 employee's externalId is not the person's id there",
150 ));
151 }
152 if !row.contains(&email) {
153 return Err(claim_refused("sso-claim-not-address", "the verified e-mail is not the employee's address"));
154 }
155 let domain = email.rsplit_once('@').map(|(_, d)| d).unwrap_or_default();
156 if domain.is_empty() || !domains.iter().any(|d| d.trim().eq_ignore_ascii_case(domain)) {
157 return Err(claim_refused(
158 "sso-claim-domain",
159 "the verified e-mail's domain is not one of the account's e-mail domains",
160 ));
161 }
162 Ok("verified_email")
163}
164
165fn claim_refused(code: &str, why: &str) -> Error {
168 refused(format!(
169 "[{code}] this sign-in cannot claim a provisioned employee: {why}. A user name \
170 (preferred_username) alone never claims; the account admin can bind the employee by \
171 setting its externalId to the person's id at the identity provider"
172 ))
173}
174
175impl AuthTable {
176 pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
178 let mut parts = key.splitn(3, '\u{1f}');
179 let _kind = parts.next();
180 parts.next() == Some(account)
181 }
182
183 pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
187 self.doc.load()?.sso_providers.get(account)
188 }
189
190 pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
192 self.doc.load()?.sso_providers.values()
193 }
194
195 pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
197 self.doc.load()?.sso_domains.get(domain)
198 }
199
200 pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
204 self.doc.update(|doc| {
205 if !doc.principals.contains_key(&provider.account)? {
206 return Err(Error::NoSuchPrincipal(provider.account.clone()));
207 }
208 if doc.employers.contains_key(&provider.account)? {
209 return Err(Error::InvalidInput {
210 what: "identity provider",
211 reason: format!(
212 "{} is an employee, not an account: only an account has an identity provider",
213 provider.account
214 ),
215 });
216 }
217 for domain in &provider.domains {
218 if let Some(holder) = doc.sso_domains.get(domain)? {
219 if holder != provider.account {
220 return Err(Error::InvalidInput {
221 what: "identity provider",
222 reason: format!("the domain {domain} already leads to another account's sign-in"),
223 });
224 }
225 }
226 }
227 if let Some(old) = doc.sso_providers.get(&provider.account)? {
228 for domain in old.domains {
229 doc.sso_domains.remove(&domain)?;
230 }
231 }
232 for domain in &provider.domains {
233 doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
234 }
235 doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
236 Ok(provider.clone())
237 })
238 }
239
240 pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
243 self.doc.update(|doc| {
244 let removed = doc.sso_providers.remove(account)?;
245 if let Some(provider) = &removed {
246 for domain in &provider.domains {
247 doc.sso_domains.remove(domain)?;
248 }
249 }
250 Ok(removed)
251 })
252 }
253
254 pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
258 self.doc.load()?.sso_users.get(principal_id)
259 }
260
261 pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
263 let mut rows: Vec<SsoUser> = self
264 .doc
265 .load()?
266 .sso_users
267 .values()?
268 .into_iter()
269 .filter(|u| u.account == account)
270 .collect();
271 rows.sort_by(|a, b| {
272 a.user_name
273 .cmp(&b.user_name)
274 .then_with(|| a.principal_id.cmp(&b.principal_id))
275 });
276 Ok(rows)
277 }
278
279 pub fn sso_principal_for_subject(
281 &self,
282 account: &str,
283 subject: &str,
284 ) -> Result<Option<String>> {
285 self.doc
286 .load()?
287 .sso_index
288 .get(&subject_index(account, subject))
289 }
290
291 fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
294 for candidate in candidates {
295 if doc.principals.contains_key(&candidate.id)? {
296 continue;
297 }
298 if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
299 continue;
300 }
301 if Self::resolve_name(doc, &candidate.id)?.is_some() {
302 continue;
303 }
304 return Ok(Some(candidate.clone()));
305 }
306 Ok(None)
307 }
308
309 fn create_directory_employee(
313 doc: &mut AuthDoc,
314 account: &str,
315 candidates: &[Principal],
316 room: &RoomAsk,
317 user: SsoUser,
318 ) -> Result<(Principal, SsoUser)> {
319 let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
320 refused(format!(
321 "no free id for {:?} in {account}: every candidate is taken",
322 user.user_name
323 ))
324 })?;
325 let principal = Principal {
326 created: user.created,
327 ..principal
328 };
329 let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
330 let ask = RoomAsk {
331 from_seat: lots.iter().any(|lot| lot.open() > 0),
332 seats: 0,
333 dry_run: false,
334 order: String::new(),
335 ..room.clone()
336 };
337 let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
338 if plan.answer.shortfall_bytes > 0 {
339 return Err(Error::RoomShort {
340 account: account.to_owned(),
341 need: plan.answer.need_bytes,
342 unused: plan.answer.unused_bytes.unwrap_or(0),
343 short: plan.answer.shortfall_bytes,
344 });
345 }
346 let mut seats = lots;
347 Self::write_employee(
348 doc,
349 account,
350 &principal,
351 plan.lot,
352 "",
353 plan.answer.room_bytes,
354 &mut seats,
355 )?;
356 if !seats.is_empty() {
357 doc.seats.insert(account.to_owned(), seats)?;
358 }
359 let user = SsoUser {
360 principal_id: principal.id.clone(),
361 ..user
362 };
363 doc.sso_index
364 .insert(user_index(account, &user.user_name), principal.id.clone())?;
365 if let Some(subject) = &user.subject {
366 doc.sso_index
367 .insert(subject_index(account, subject), principal.id.clone())?;
368 }
369 doc.sso_users.insert(principal.id.clone(), user.clone())?;
370 Ok((principal, user))
371 }
372
373 pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
389 let session_key = match &login.session {
390 Some((openssh, expires)) => {
391 let parsed = super::parse_public_key(openssh)?;
392 let fingerprint = super::Fingerprint::of_key(&parsed);
393 Some(PublicKey {
394 fingerprint_sha256: fingerprint.as_str().to_owned(),
395 algo: parsed.algorithm().as_str().to_owned(),
396 principal_id: String::new(),
397 added: login.now,
398 last_used: None,
399 expires: Some(*expires),
400 openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
401 })
402 }
403 None => None,
404 };
405 let account = login.account.as_str();
406 self.doc.update(|doc| {
407 if !doc.sso_providers.contains_key(account)? {
408 return Err(refused(format!("{account} has no identity provider")));
409 }
410 let mut created = false;
411 let mut claimed = false;
412 let mut claimed_by = None;
413 let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
414 let (principal, mut user) = match bound {
415 Some(id) => {
416 let user = doc
417 .sso_users
418 .get(&id)?
419 .filter(|u| u.account == account)
420 .ok_or_else(|| refused("[sso-subject-elsewhere] this identity is bound to an employee outside this account's directory".to_owned()))?;
421 let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
422 (principal, user)
423 }
424 None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
425 Some(id) => {
426 let mut user = doc
427 .sso_users
428 .get(&id)?
429 .filter(|u| u.account == account && !u.deleted)
430 .ok_or_else(|| refused("[sso-claim-not-member] this sign-in's user is not in the account's directory".to_owned()))?;
431 if user.subject.is_some() {
432 return Err(refused(
433 "[sso-claim-bound] this directory user is already bound to another identity at \
434 the IdP; it is never re-pointed — the account admin removes it first"
435 .to_owned(),
436 ));
437 }
438 if user.source == SsoSource::Jit {
439 return Err(refused("[sso-claim-jit] this directory user was created by another login".to_owned()));
440 }
441 let domains = doc
442 .sso_providers
443 .get(account)?
444 .map(|p| p.domains)
445 .unwrap_or_default();
446 claimed_by = Some(claim_basis(&user, login, &domains)?);
447 user.subject = Some(login.subject.clone());
448 doc.sso_index.insert(subject_index(account, &login.subject), id.clone())?;
449 claimed = true;
450 let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
451 (principal, user)
452 }
453 None => {
454 let user = SsoUser {
455 principal_id: String::new(),
456 account: account.to_owned(),
457 user_name: login.user_name.clone(),
458 display_name: login.display_name.clone(),
459 email: login.email.clone(),
460 external_id: String::new(),
461 subject: Some(login.subject.clone()),
462 active: true,
463 source: SsoSource::Jit,
464 created: login.now,
465 last_login: None,
466 sessions: Vec::new(),
467 deleted: false,
468 };
469 created = true;
470 Self::create_directory_employee(doc, account, &login.candidates, &login.room, user)?
471 }
472 },
473 };
474 if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
477 return Err(refused(format!("{} is not an employee of {account}", principal.id)));
478 }
479 if !user.active || user.deleted {
480 return Err(refused(format!(
481 "{} is deactivated in {account}'s directory",
482 principal.id
483 )));
484 }
485 if Self::gated(doc, &principal.id)? {
486 return Err(refused(format!("{} is locked out", principal.id)));
487 }
488 if !created {
489 if !login.display_name.is_empty() {
490 user.display_name = login.display_name.clone();
491 }
492 if !login.email.is_empty() {
493 user.email = login.email.clone();
494 }
495 }
496 user.last_login = Some(login.now);
497 let mut enrolled = None;
498 if let Some(key) = &session_key {
499 if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
500 return Err(Error::KeyExists {
501 fingerprint: key.fingerprint_sha256.clone(),
502 principal_id: existing.principal_id,
503 });
504 }
505 let key = PublicKey { principal_id: principal.id.clone(), ..key.clone() };
506 let mut live = Vec::with_capacity(user.sessions.len() + 1);
507 for held in std::mem::take(&mut user.sessions) {
508 if doc.keys.get(&held)?.is_some_and(|k| k.is_valid_at(login.now)) {
509 live.push(held);
510 }
511 }
512 live.push(key.fingerprint_sha256.clone());
513 user.sessions = live;
514 doc.keys.insert(key.fingerprint_sha256.clone(), key.clone())?;
515 enrolled = Some(key);
516 }
517 doc.sso_users.insert(principal.id.clone(), user.clone())?;
518 Ok(SsoLoginDone { principal, user, created, claimed, claimed_by, session: enrolled })
519 })
520 }
521
522 pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
527 let account = provision.account.as_str();
528 self.doc.update(|doc| {
529 if !doc.principals.contains_key(account)? {
530 return Err(Error::NoSuchPrincipal(account.to_owned()));
531 }
532 if let Some(held) = doc
533 .sso_index
534 .get(&user_index(account, &provision.user_name))?
535 {
536 return Err(Error::NameExists {
537 name: provision.user_name.clone(),
538 principal_id: held,
539 });
540 }
541 let user = SsoUser {
542 principal_id: String::new(),
543 account: account.to_owned(),
544 user_name: provision.user_name.clone(),
545 display_name: provision.display_name.clone(),
546 email: provision.email.clone(),
547 external_id: provision.external_id.clone(),
548 subject: None,
549 active: true,
550 source: provision.source,
551 created: provision.now,
552 last_login: None,
553 sessions: Vec::new(),
554 deleted: false,
555 };
556 let (_, mut user) = Self::create_directory_employee(
557 doc,
558 account,
559 &provision.candidates,
560 &provision.room,
561 user,
562 )?;
563 if !provision.active {
564 Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
565 doc.sso_users
566 .insert(user.principal_id.clone(), user.clone())?;
567 }
568 Ok(user)
569 })
570 }
571
572 pub fn sso_user_update(
578 &self,
579 account: &str,
580 principal_id: &str,
581 patch: &SsoUserPatch,
582 actor: &str,
583 now: UnixMillis,
584 ) -> Result<(SsoUser, bool)> {
585 self.doc.update(|doc| {
586 let mut user = doc
587 .sso_users
588 .get(principal_id)?
589 .filter(|u| u.account == account && !u.deleted)
590 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
591 if let Some(name) = &patch.user_name {
592 if *name != user.user_name {
593 if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
594 if held != user.principal_id {
595 return Err(Error::NameExists {
596 name: name.clone(),
597 principal_id: held,
598 });
599 }
600 }
601 doc.sso_index
602 .remove(&user_index(account, &user.user_name))?;
603 doc.sso_index
604 .insert(user_index(account, name), user.principal_id.clone())?;
605 user.user_name = name.clone();
606 }
607 }
608 if let Some(v) = &patch.display_name {
609 user.display_name = v.clone();
610 }
611 if let Some(v) = &patch.email {
612 user.email = v.clone();
613 }
614 if let Some(v) = &patch.external_id {
615 user.external_id = v.clone();
616 }
617 let mut changed = false;
618 match patch.active {
619 Some(false) if user.active => {
620 Self::deactivate(doc, &mut user, actor, now)?;
621 changed = true;
622 }
623 Some(true) if !user.active => {
624 user.active = true;
625 let holder = crate::sso::gate_holder(account);
626 if doc
627 .gates
628 .get(&user.principal_id)?
629 .is_some_and(|g| g.holder == holder)
630 {
631 doc.gates.remove(&user.principal_id)?;
632 }
633 changed = true;
634 }
635 _ => {}
636 }
637 doc.sso_users
638 .insert(user.principal_id.clone(), user.clone())?;
639 Ok((user, changed))
640 })
641 }
642
643 pub fn sso_user_delete(
648 &self,
649 account: &str,
650 principal_id: &str,
651 actor: &str,
652 now: UnixMillis,
653 ) -> Result<SsoUser> {
654 self.doc.update(|doc| {
655 let mut user = doc
656 .sso_users
657 .get(principal_id)?
658 .filter(|u| u.account == account && !u.deleted)
659 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
660 if user.active {
661 Self::deactivate(doc, &mut user, actor, now)?;
662 }
663 user.deleted = true;
664 doc.sso_index
665 .remove(&user_index(account, &user.user_name))?;
666 for (gid, mut group) in doc.sso_groups.entries()? {
667 if group.account == account && group.members.contains(&user.principal_id) {
668 group.members.retain(|m| *m != user.principal_id);
669 doc.sso_groups.insert(gid, group)?;
670 }
671 }
672 doc.sso_users
673 .insert(user.principal_id.clone(), user.clone())?;
674 Ok(user)
675 })
676 }
677
678 fn deactivate(
682 doc: &mut AuthDoc,
683 user: &mut SsoUser,
684 actor: &str,
685 now: UnixMillis,
686 ) -> Result<()> {
687 user.active = false;
688 let id = user.principal_id.clone();
689 let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
690 for (cid, mut credential) in doc.credentials.entries()? {
691 if credential.principal_id == id && !credential.sessions.is_empty() {
692 ended.append(&mut credential.sessions);
693 doc.credentials.insert(cid, credential)?;
694 }
695 }
696 for fingerprint in ended {
697 if doc
698 .keys
699 .get(&fingerprint)?
700 .is_some_and(|k| k.principal_id == id)
701 {
702 doc.keys.remove(&fingerprint)?;
703 }
704 }
705 let holder = crate::sso::gate_holder(&user.account);
706 let held_by_another = doc
707 .gates
708 .get(&id)?
709 .is_some_and(|g| g.holder != holder && g.is_live_at(now));
710 if !held_by_another {
711 doc.gates.insert(
712 id.clone(),
713 AccountGate {
714 account: id.clone(),
715 reason: GateReason::Administrative,
716 holder,
717 actor: actor.to_owned(),
718 note: format!("deactivated in {}'s directory", user.account),
719 at: now,
720 expires: None,
721 },
722 )?;
723 }
724 Ok(())
725 }
726
727 pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
731 self.doc.update(|doc| {
732 if !doc.principals.contains_key(&token.account)? {
733 return Err(Error::NoSuchPrincipal(token.account.clone()));
734 }
735 if doc.scim_tokens.contains_key(&token.secret_hash)? {
736 return Err(Error::TokenExists {
737 hash: token.secret_hash.clone(),
738 principal_id: token.account.clone(),
739 });
740 }
741 doc.scim_tokens
742 .insert(token.secret_hash.clone(), token.clone())?;
743 Ok(token.clone())
744 })
745 }
746
747 pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
749 self.doc.load()?.scim_tokens.get(hash)
750 }
751
752 pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
754 let mut rows: Vec<ScimToken> = self
755 .doc
756 .load()?
757 .scim_tokens
758 .values()?
759 .into_iter()
760 .filter(|t| t.account == account)
761 .collect();
762 rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
763 Ok(rows)
764 }
765
766 pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
768 self.doc.update(|doc| {
769 let hit = doc
770 .scim_tokens
771 .values()?
772 .into_iter()
773 .find(|t| t.account == account && t.id == id);
774 match hit {
775 Some(token) => doc.scim_tokens.remove(&token.secret_hash),
776 None => Ok(None),
777 }
778 })
779 }
780
781 pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
783 let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
784 t.last_used
785 .is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
786 });
787 if !stale {
788 return Ok(());
789 }
790 self.doc.update(|doc| {
791 doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
792 Ok(())
793 })
794 }
795
796 pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
800 let mut rows: Vec<SsoGroup> = self
801 .doc
802 .load()?
803 .sso_groups
804 .values()?
805 .into_iter()
806 .filter(|g| g.account == account)
807 .collect();
808 rows.sort_by(|a, b| {
809 a.display_name
810 .cmp(&b.display_name)
811 .then_with(|| a.id.cmp(&b.id))
812 });
813 Ok(rows)
814 }
815
816 pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
819 self.doc.update(|doc| {
820 for member in &group.members {
821 let ok = doc
822 .sso_users
823 .get(member)?
824 .is_some_and(|u| u.account == group.account && !u.deleted);
825 if !ok {
826 return Err(Error::InvalidInput {
827 what: "group",
828 reason: format!("{member} is not a user of {}'s directory", group.account),
829 });
830 }
831 }
832 for other in doc.sso_groups.values()? {
833 if other.account == group.account
834 && other.id != group.id
835 && other.display_name.eq_ignore_ascii_case(&group.display_name)
836 {
837 return Err(Error::NameExists {
838 name: group.display_name.clone(),
839 principal_id: other.id,
840 });
841 }
842 }
843 if let Some(old) = doc.sso_groups.get(&group.id)? {
844 if old.account != group.account {
845 return Err(Error::InvalidInput {
846 what: "group",
847 reason: "the group is another account's".to_owned(),
848 });
849 }
850 }
851 doc.sso_groups.insert(group.id.clone(), group.clone())?;
852 Ok(group.clone())
853 })
854 }
855
856 pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
858 self.doc.update(|doc| match doc.sso_groups.get(id)? {
859 Some(group) if group.account == account => doc.sso_groups.remove(id),
860 _ => Ok(None),
861 })
862 }
863
864 pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
867 let doc = self.doc.load()?;
868 let Some(user) = doc.sso_users.get(principal_id)? else {
869 return Ok(0);
870 };
871 let mut n = 0;
872 for fp in &user.sessions {
873 if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
874 n += 1;
875 }
876 }
877 Ok(n)
878 }
879}
880
881#[cfg(test)]
882mod older_reader_tests {
883 use std::collections::BTreeSet;
884 use std::sync::Arc;
885
886 use super::super::{AuthTable, AUTH_REF};
887 use crate::control::ControlStore;
888 use crate::model::{Principal, PrincipalKind};
889 use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};
890
891 const OLDER: &[&str] = &[
897 "principals", "keys", "grants", "tokens", "memberships", "bootstrap", "gates", "names",
898 "employers", "rooms", "seats", "room_orders", "credentials", "invites",
899 ];
900
901 fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
902 let id = store.read_doc(AUTH_REF).unwrap().expect("the table was written");
903 let bytes = store.get_blob(&id).unwrap().expect("its blob");
904 let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
905 doc.as_object().expect("an object").keys().cloned().collect()
906 }
907
908 fn provider(account: &str) -> SsoProvider {
909 SsoProvider {
910 account: account.to_owned(),
911 protocol: SsoProtocol::Oidc,
912 display_name: format!("{account} corp"),
913 domains: vec![format!("{account}.example")],
914 oidc: Some(OidcSettings {
915 issuer: "https://idp.example.test".into(),
916 client_id: "c".into(),
917 ..Default::default()
918 }),
919 saml: None,
920 require_sso: false,
921 require_passkey: false,
922 console_origin: "https://gunnar.example".into(),
923 updated: 1,
924 updated_by: account.to_owned(),
925 }
926 }
927
928 #[test]
933 fn sso_maps_are_written_only_when_used() {
934 let store = crate::memory_control_store().expect("store");
935 let auth = AuthTable::new(Arc::clone(&store)).expect("table");
936 auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
937 .expect("account");
938 let keys = stored_keys(&store);
939 let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
940 assert!(
941 unknown.is_empty(),
942 "a table without SSO carries keys an older gunnar refuses: {unknown:?}"
943 );
944
945 auth.set_sso_provider(provider("acme")).expect("provider");
946 let keys = stored_keys(&store);
947 assert!(
948 keys.contains("sso_providers") && keys.contains("sso_domains"),
949 "a provider is written: {keys:?}"
950 );
951
952 auth.remove_sso_provider("acme").expect("removed");
953 let keys = stored_keys(&store);
954 let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
955 assert!(unknown.is_empty(), "the provider is gone, its maps must be too: {unknown:?}");
956 }
957}