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 candidates: Vec<Principal>,
32 pub room: RoomAsk,
35 pub session: Option<(String, UnixMillis)>,
38 pub now: UnixMillis,
40}
41
42#[derive(Clone, Debug)]
44pub struct SsoLoginDone {
45 pub principal: Principal,
47 pub user: SsoUser,
49 pub created: bool,
51 pub claimed: bool,
53 pub session: Option<PublicKey>,
55}
56
57#[derive(Clone, Debug)]
59pub struct SsoProvision {
60 pub account: String,
62 pub user_name: String,
64 pub display_name: String,
66 pub email: String,
68 pub external_id: String,
70 pub active: bool,
72 pub source: SsoSource,
74 pub candidates: Vec<Principal>,
76 pub room: RoomAsk,
78 pub actor: String,
80 pub now: UnixMillis,
82}
83
84#[derive(Clone, Debug, Default)]
86pub struct SsoUserPatch {
87 pub user_name: Option<String>,
89 pub display_name: Option<String>,
91 pub email: Option<String>,
93 pub external_id: Option<String>,
95 pub active: Option<bool>,
97}
98
99fn subject_index(account: &str, subject: &str) -> String {
100 format!("s\u{1f}{account}\u{1f}{subject}")
101}
102
103fn user_index(account: &str, user_name: &str) -> String {
104 format!("u\u{1f}{account}\u{1f}{user_name}")
105}
106
107fn refused(reason: impl Into<String>) -> Error {
108 Error::SsoRefused(reason.into())
109}
110
111impl AuthTable {
112 pub(super) fn sso_index_of_account(key: &str, account: &str) -> bool {
114 let mut parts = key.splitn(3, '\u{1f}');
115 let _kind = parts.next();
116 parts.next() == Some(account)
117 }
118
119 pub fn sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
123 self.doc.load()?.sso_providers.get(account)
124 }
125
126 pub fn sso_providers(&self) -> Result<Vec<SsoProvider>> {
128 self.doc.load()?.sso_providers.values()
129 }
130
131 pub fn sso_account_for_domain(&self, domain: &str) -> Result<Option<String>> {
133 self.doc.load()?.sso_domains.get(domain)
134 }
135
136 pub fn set_sso_provider(&self, provider: SsoProvider) -> Result<SsoProvider> {
140 self.doc.update(|doc| {
141 if !doc.principals.contains_key(&provider.account)? {
142 return Err(Error::NoSuchPrincipal(provider.account.clone()));
143 }
144 if doc.employers.contains_key(&provider.account)? {
145 return Err(Error::InvalidInput {
146 what: "identity provider",
147 reason: format!(
148 "{} is an employee, not an account: only an account has an identity provider",
149 provider.account
150 ),
151 });
152 }
153 for domain in &provider.domains {
154 if let Some(holder) = doc.sso_domains.get(domain)? {
155 if holder != provider.account {
156 return Err(Error::InvalidInput {
157 what: "identity provider",
158 reason: format!("the domain {domain} already leads to another account's sign-in"),
159 });
160 }
161 }
162 }
163 if let Some(old) = doc.sso_providers.get(&provider.account)? {
164 for domain in old.domains {
165 doc.sso_domains.remove(&domain)?;
166 }
167 }
168 for domain in &provider.domains {
169 doc.sso_domains.insert(domain.clone(), provider.account.clone())?;
170 }
171 doc.sso_providers.insert(provider.account.clone(), provider.clone())?;
172 Ok(provider.clone())
173 })
174 }
175
176 pub fn remove_sso_provider(&self, account: &str) -> Result<Option<SsoProvider>> {
179 self.doc.update(|doc| {
180 let removed = doc.sso_providers.remove(account)?;
181 if let Some(provider) = &removed {
182 for domain in &provider.domains {
183 doc.sso_domains.remove(domain)?;
184 }
185 }
186 Ok(removed)
187 })
188 }
189
190 pub fn sso_user(&self, principal_id: &str) -> Result<Option<SsoUser>> {
194 self.doc.load()?.sso_users.get(principal_id)
195 }
196
197 pub fn sso_users_of(&self, account: &str) -> Result<Vec<SsoUser>> {
199 let mut rows: Vec<SsoUser> = self
200 .doc
201 .load()?
202 .sso_users
203 .values()?
204 .into_iter()
205 .filter(|u| u.account == account)
206 .collect();
207 rows.sort_by(|a, b| {
208 a.user_name
209 .cmp(&b.user_name)
210 .then_with(|| a.principal_id.cmp(&b.principal_id))
211 });
212 Ok(rows)
213 }
214
215 pub fn sso_principal_for_subject(
217 &self,
218 account: &str,
219 subject: &str,
220 ) -> Result<Option<String>> {
221 self.doc
222 .load()?
223 .sso_index
224 .get(&subject_index(account, subject))
225 }
226
227 fn free_candidate(doc: &AuthDoc, candidates: &[Principal]) -> Result<Option<Principal>> {
230 for candidate in candidates {
231 if doc.principals.contains_key(&candidate.id)? {
232 continue;
233 }
234 if Self::refuse_taken_name(doc, &candidate.name, &candidate.id).is_err() {
235 continue;
236 }
237 if Self::resolve_name(doc, &candidate.id)?.is_some() {
238 continue;
239 }
240 return Ok(Some(candidate.clone()));
241 }
242 Ok(None)
243 }
244
245 fn create_directory_employee(
249 doc: &mut AuthDoc,
250 account: &str,
251 candidates: &[Principal],
252 room: &RoomAsk,
253 user: SsoUser,
254 ) -> Result<(Principal, SsoUser)> {
255 let principal = Self::free_candidate(doc, candidates)?.ok_or_else(|| {
256 refused(format!(
257 "no free id for {:?} in {account}: every candidate is taken",
258 user.user_name
259 ))
260 })?;
261 let principal = Principal {
262 created: user.created,
263 ..principal
264 };
265 let lots: Vec<SeatLot> = doc.seats.get(account)?.unwrap_or_default();
266 let ask = RoomAsk {
267 from_seat: lots.iter().any(|lot| lot.open() > 0),
268 seats: 0,
269 dry_run: false,
270 order: String::new(),
271 ..room.clone()
272 };
273 let plan = Self::plan_room(doc, account, Some(&principal), &ask)?;
274 if plan.answer.shortfall_bytes > 0 {
275 return Err(Error::RoomShort {
276 account: account.to_owned(),
277 need: plan.answer.need_bytes,
278 unused: plan.answer.unused_bytes.unwrap_or(0),
279 short: plan.answer.shortfall_bytes,
280 });
281 }
282 let mut seats = lots;
283 Self::write_employee(
284 doc,
285 account,
286 &principal,
287 plan.lot,
288 "",
289 plan.answer.room_bytes,
290 &mut seats,
291 )?;
292 if !seats.is_empty() {
293 doc.seats.insert(account.to_owned(), seats)?;
294 }
295 let user = SsoUser {
296 principal_id: principal.id.clone(),
297 ..user
298 };
299 doc.sso_index
300 .insert(user_index(account, &user.user_name), principal.id.clone())?;
301 if let Some(subject) = &user.subject {
302 doc.sso_index
303 .insert(subject_index(account, subject), principal.id.clone())?;
304 }
305 doc.sso_users.insert(principal.id.clone(), user.clone())?;
306 Ok((principal, user))
307 }
308
309 pub fn sso_login(&self, login: &SsoLogin) -> Result<SsoLoginDone> {
321 let session_key = match &login.session {
322 Some((openssh, expires)) => {
323 let parsed = super::parse_public_key(openssh)?;
324 let fingerprint = super::Fingerprint::of_key(&parsed);
325 Some(PublicKey {
326 fingerprint_sha256: fingerprint.as_str().to_owned(),
327 algo: parsed.algorithm().as_str().to_owned(),
328 principal_id: String::new(),
329 added: login.now,
330 last_used: None,
331 expires: Some(*expires),
332 openssh: parsed.to_openssh().map_err(Error::from)?.trim().to_owned(),
333 })
334 }
335 None => None,
336 };
337 let account = login.account.as_str();
338 self.doc.update(|doc| {
339 if !doc.sso_providers.contains_key(account)? {
340 return Err(refused(format!("{account} has no identity provider")));
341 }
342 let mut created = false;
343 let mut claimed = false;
344 let bound = doc.sso_index.get(&subject_index(account, &login.subject))?;
345 let (principal, mut user) = match bound {
346 Some(id) => {
347 let user = doc
348 .sso_users
349 .get(&id)?
350 .filter(|u| u.account == account)
351 .ok_or_else(|| refused(format!("the subject is bound to {id}, which is not in {account}'s directory")))?;
352 let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
353 (principal, user)
354 }
355 None => match doc.sso_index.get(&user_index(account, &login.user_name))? {
356 Some(id) => {
357 let mut user = doc
358 .sso_users
359 .get(&id)?
360 .filter(|u| u.account == account && !u.deleted)
361 .ok_or_else(|| refused(format!("{:?} is not in {account}'s directory", login.user_name)))?;
362 if user.subject.is_some() {
363 return Err(refused(format!(
364 "the directory user {:?} is already bound to another identity at the IdP; \
365 it is never re-pointed — the account admin removes it first",
366 login.user_name
367 )));
368 }
369 if user.source == SsoSource::Jit {
370 return Err(refused(format!("{:?} was created by another login", login.user_name)));
371 }
372 user.subject = Some(login.subject.clone());
373 doc.sso_index.insert(subject_index(account, &login.subject), id.clone())?;
374 claimed = true;
375 let principal = doc.principals.get(&id)?.ok_or_else(|| Error::NoSuchPrincipal(id.clone()))?;
376 (principal, user)
377 }
378 None => {
379 let user = SsoUser {
380 principal_id: String::new(),
381 account: account.to_owned(),
382 user_name: login.user_name.clone(),
383 display_name: login.display_name.clone(),
384 email: login.email.clone(),
385 external_id: String::new(),
386 subject: Some(login.subject.clone()),
387 active: true,
388 source: SsoSource::Jit,
389 created: login.now,
390 last_login: None,
391 sessions: Vec::new(),
392 deleted: false,
393 };
394 created = true;
395 Self::create_directory_employee(doc, account, &login.candidates, &login.room, user)?
396 }
397 },
398 };
399 if doc.employers.get(&principal.id)?.as_deref() != Some(account) {
402 return Err(refused(format!("{} is not an employee of {account}", principal.id)));
403 }
404 if !user.active || user.deleted {
405 return Err(refused(format!(
406 "{} is deactivated in {account}'s directory",
407 principal.id
408 )));
409 }
410 if Self::gated(doc, &principal.id)? {
411 return Err(refused(format!("{} is locked out", principal.id)));
412 }
413 if !created {
414 if !login.display_name.is_empty() {
415 user.display_name = login.display_name.clone();
416 }
417 if !login.email.is_empty() {
418 user.email = login.email.clone();
419 }
420 }
421 user.last_login = Some(login.now);
422 let mut enrolled = None;
423 if let Some(key) = &session_key {
424 if let Some(existing) = doc.keys.get(&key.fingerprint_sha256)? {
425 return Err(Error::KeyExists {
426 fingerprint: key.fingerprint_sha256.clone(),
427 principal_id: existing.principal_id,
428 });
429 }
430 let key = PublicKey { principal_id: principal.id.clone(), ..key.clone() };
431 let mut live = Vec::with_capacity(user.sessions.len() + 1);
432 for held in std::mem::take(&mut user.sessions) {
433 if doc.keys.get(&held)?.is_some_and(|k| k.is_valid_at(login.now)) {
434 live.push(held);
435 }
436 }
437 live.push(key.fingerprint_sha256.clone());
438 user.sessions = live;
439 doc.keys.insert(key.fingerprint_sha256.clone(), key.clone())?;
440 enrolled = Some(key);
441 }
442 doc.sso_users.insert(principal.id.clone(), user.clone())?;
443 Ok(SsoLoginDone { principal, user, created, claimed, session: enrolled })
444 })
445 }
446
447 pub fn sso_provision(&self, provision: &SsoProvision) -> Result<SsoUser> {
452 let account = provision.account.as_str();
453 self.doc.update(|doc| {
454 if !doc.principals.contains_key(account)? {
455 return Err(Error::NoSuchPrincipal(account.to_owned()));
456 }
457 if let Some(held) = doc
458 .sso_index
459 .get(&user_index(account, &provision.user_name))?
460 {
461 return Err(Error::NameExists {
462 name: provision.user_name.clone(),
463 principal_id: held,
464 });
465 }
466 let user = SsoUser {
467 principal_id: String::new(),
468 account: account.to_owned(),
469 user_name: provision.user_name.clone(),
470 display_name: provision.display_name.clone(),
471 email: provision.email.clone(),
472 external_id: provision.external_id.clone(),
473 subject: None,
474 active: true,
475 source: provision.source,
476 created: provision.now,
477 last_login: None,
478 sessions: Vec::new(),
479 deleted: false,
480 };
481 let (_, mut user) = Self::create_directory_employee(
482 doc,
483 account,
484 &provision.candidates,
485 &provision.room,
486 user,
487 )?;
488 if !provision.active {
489 Self::deactivate(doc, &mut user, &provision.actor, provision.now)?;
490 doc.sso_users
491 .insert(user.principal_id.clone(), user.clone())?;
492 }
493 Ok(user)
494 })
495 }
496
497 pub fn sso_user_update(
503 &self,
504 account: &str,
505 principal_id: &str,
506 patch: &SsoUserPatch,
507 actor: &str,
508 now: UnixMillis,
509 ) -> Result<(SsoUser, bool)> {
510 self.doc.update(|doc| {
511 let mut user = doc
512 .sso_users
513 .get(principal_id)?
514 .filter(|u| u.account == account && !u.deleted)
515 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
516 if let Some(name) = &patch.user_name {
517 if *name != user.user_name {
518 if let Some(held) = doc.sso_index.get(&user_index(account, name))? {
519 if held != user.principal_id {
520 return Err(Error::NameExists {
521 name: name.clone(),
522 principal_id: held,
523 });
524 }
525 }
526 doc.sso_index
527 .remove(&user_index(account, &user.user_name))?;
528 doc.sso_index
529 .insert(user_index(account, name), user.principal_id.clone())?;
530 user.user_name = name.clone();
531 }
532 }
533 if let Some(v) = &patch.display_name {
534 user.display_name = v.clone();
535 }
536 if let Some(v) = &patch.email {
537 user.email = v.clone();
538 }
539 if let Some(v) = &patch.external_id {
540 user.external_id = v.clone();
541 }
542 let mut changed = false;
543 match patch.active {
544 Some(false) if user.active => {
545 Self::deactivate(doc, &mut user, actor, now)?;
546 changed = true;
547 }
548 Some(true) if !user.active => {
549 user.active = true;
550 let holder = crate::sso::gate_holder(account);
551 if doc
552 .gates
553 .get(&user.principal_id)?
554 .is_some_and(|g| g.holder == holder)
555 {
556 doc.gates.remove(&user.principal_id)?;
557 }
558 changed = true;
559 }
560 _ => {}
561 }
562 doc.sso_users
563 .insert(user.principal_id.clone(), user.clone())?;
564 Ok((user, changed))
565 })
566 }
567
568 pub fn sso_user_delete(
573 &self,
574 account: &str,
575 principal_id: &str,
576 actor: &str,
577 now: UnixMillis,
578 ) -> Result<SsoUser> {
579 self.doc.update(|doc| {
580 let mut user = doc
581 .sso_users
582 .get(principal_id)?
583 .filter(|u| u.account == account && !u.deleted)
584 .ok_or_else(|| Error::NoSuchPrincipal(principal_id.to_owned()))?;
585 if user.active {
586 Self::deactivate(doc, &mut user, actor, now)?;
587 }
588 user.deleted = true;
589 doc.sso_index
590 .remove(&user_index(account, &user.user_name))?;
591 for (gid, mut group) in doc.sso_groups.entries()? {
592 if group.account == account && group.members.contains(&user.principal_id) {
593 group.members.retain(|m| *m != user.principal_id);
594 doc.sso_groups.insert(gid, group)?;
595 }
596 }
597 doc.sso_users
598 .insert(user.principal_id.clone(), user.clone())?;
599 Ok(user)
600 })
601 }
602
603 fn deactivate(
607 doc: &mut AuthDoc,
608 user: &mut SsoUser,
609 actor: &str,
610 now: UnixMillis,
611 ) -> Result<()> {
612 user.active = false;
613 let id = user.principal_id.clone();
614 let mut ended: Vec<String> = std::mem::take(&mut user.sessions);
615 for (cid, mut credential) in doc.credentials.entries()? {
616 if credential.principal_id == id && !credential.sessions.is_empty() {
617 ended.append(&mut credential.sessions);
618 doc.credentials.insert(cid, credential)?;
619 }
620 }
621 for fingerprint in ended {
622 if doc
623 .keys
624 .get(&fingerprint)?
625 .is_some_and(|k| k.principal_id == id)
626 {
627 doc.keys.remove(&fingerprint)?;
628 }
629 }
630 let holder = crate::sso::gate_holder(&user.account);
631 let held_by_another = doc
632 .gates
633 .get(&id)?
634 .is_some_and(|g| g.holder != holder && g.is_live_at(now));
635 if !held_by_another {
636 doc.gates.insert(
637 id.clone(),
638 AccountGate {
639 account: id.clone(),
640 reason: GateReason::Administrative,
641 holder,
642 actor: actor.to_owned(),
643 note: format!("deactivated in {}'s directory", user.account),
644 at: now,
645 expires: None,
646 },
647 )?;
648 }
649 Ok(())
650 }
651
652 pub fn create_scim_token(&self, token: ScimToken) -> Result<ScimToken> {
656 self.doc.update(|doc| {
657 if !doc.principals.contains_key(&token.account)? {
658 return Err(Error::NoSuchPrincipal(token.account.clone()));
659 }
660 if doc.scim_tokens.contains_key(&token.secret_hash)? {
661 return Err(Error::TokenExists {
662 hash: token.secret_hash.clone(),
663 principal_id: token.account.clone(),
664 });
665 }
666 doc.scim_tokens
667 .insert(token.secret_hash.clone(), token.clone())?;
668 Ok(token.clone())
669 })
670 }
671
672 pub fn scim_token_by_hash(&self, hash: &str) -> Result<Option<ScimToken>> {
674 self.doc.load()?.scim_tokens.get(hash)
675 }
676
677 pub fn scim_tokens_of(&self, account: &str) -> Result<Vec<ScimToken>> {
679 let mut rows: Vec<ScimToken> = self
680 .doc
681 .load()?
682 .scim_tokens
683 .values()?
684 .into_iter()
685 .filter(|t| t.account == account)
686 .collect();
687 rows.sort_by(|a, b| (a.created, &a.id).cmp(&(b.created, &b.id)));
688 Ok(rows)
689 }
690
691 pub fn revoke_scim_token(&self, account: &str, id: &str) -> Result<Option<ScimToken>> {
693 self.doc.update(|doc| {
694 let hit = doc
695 .scim_tokens
696 .values()?
697 .into_iter()
698 .find(|t| t.account == account && t.id == id);
699 match hit {
700 Some(token) => doc.scim_tokens.remove(&token.secret_hash),
701 None => Ok(None),
702 }
703 })
704 }
705
706 pub fn touch_scim_token(&self, hash: &str, now: UnixMillis) -> Result<()> {
708 let stale = self.doc.load()?.scim_tokens.get(hash)?.is_some_and(|t| {
709 t.last_used
710 .is_none_or(|at| now - at >= super::LAST_USED_GRANULARITY)
711 });
712 if !stale {
713 return Ok(());
714 }
715 self.doc.update(|doc| {
716 doc.scim_tokens.modify(hash, |t| t.last_used = Some(now))?;
717 Ok(())
718 })
719 }
720
721 pub fn sso_groups_of(&self, account: &str) -> Result<Vec<SsoGroup>> {
725 let mut rows: Vec<SsoGroup> = self
726 .doc
727 .load()?
728 .sso_groups
729 .values()?
730 .into_iter()
731 .filter(|g| g.account == account)
732 .collect();
733 rows.sort_by(|a, b| {
734 a.display_name
735 .cmp(&b.display_name)
736 .then_with(|| a.id.cmp(&b.id))
737 });
738 Ok(rows)
739 }
740
741 pub fn put_sso_group(&self, group: SsoGroup) -> Result<SsoGroup> {
744 self.doc.update(|doc| {
745 for member in &group.members {
746 let ok = doc
747 .sso_users
748 .get(member)?
749 .is_some_and(|u| u.account == group.account && !u.deleted);
750 if !ok {
751 return Err(Error::InvalidInput {
752 what: "group",
753 reason: format!("{member} is not a user of {}'s directory", group.account),
754 });
755 }
756 }
757 for other in doc.sso_groups.values()? {
758 if other.account == group.account
759 && other.id != group.id
760 && other.display_name.eq_ignore_ascii_case(&group.display_name)
761 {
762 return Err(Error::NameExists {
763 name: group.display_name.clone(),
764 principal_id: other.id,
765 });
766 }
767 }
768 if let Some(old) = doc.sso_groups.get(&group.id)? {
769 if old.account != group.account {
770 return Err(Error::InvalidInput {
771 what: "group",
772 reason: "the group is another account's".to_owned(),
773 });
774 }
775 }
776 doc.sso_groups.insert(group.id.clone(), group.clone())?;
777 Ok(group.clone())
778 })
779 }
780
781 pub fn remove_sso_group(&self, account: &str, id: &str) -> Result<Option<SsoGroup>> {
783 self.doc.update(|doc| match doc.sso_groups.get(id)? {
784 Some(group) if group.account == account => doc.sso_groups.remove(id),
785 _ => Ok(None),
786 })
787 }
788
789 pub fn sso_live_sessions(&self, principal_id: &str, now: UnixMillis) -> Result<usize> {
792 let doc = self.doc.load()?;
793 let Some(user) = doc.sso_users.get(principal_id)? else {
794 return Ok(0);
795 };
796 let mut n = 0;
797 for fp in &user.sessions {
798 if doc.keys.get(fp)?.is_some_and(|k| k.is_valid_at(now)) {
799 n += 1;
800 }
801 }
802 Ok(n)
803 }
804}
805
806#[cfg(test)]
807mod older_reader_tests {
808 use std::collections::BTreeSet;
809 use std::sync::Arc;
810
811 use super::super::{AuthTable, AUTH_REF};
812 use crate::control::ControlStore;
813 use crate::model::{Principal, PrincipalKind};
814 use crate::sso::{OidcSettings, SsoProtocol, SsoProvider};
815
816 const OLDER: &[&str] = &[
822 "principals", "keys", "grants", "tokens", "memberships", "bootstrap", "gates", "names",
823 "employers", "rooms", "seats", "room_orders", "credentials", "invites",
824 ];
825
826 fn stored_keys(store: &Arc<dyn ControlStore>) -> BTreeSet<String> {
827 let id = store.read_doc(AUTH_REF).unwrap().expect("the table was written");
828 let bytes = store.get_blob(&id).unwrap().expect("its blob");
829 let doc: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
830 doc.as_object().expect("an object").keys().cloned().collect()
831 }
832
833 fn provider(account: &str) -> SsoProvider {
834 SsoProvider {
835 account: account.to_owned(),
836 protocol: SsoProtocol::Oidc,
837 display_name: format!("{account} corp"),
838 domains: vec![format!("{account}.example")],
839 oidc: Some(OidcSettings {
840 issuer: "https://idp.example.test".into(),
841 client_id: "c".into(),
842 ..Default::default()
843 }),
844 saml: None,
845 require_sso: false,
846 require_passkey: false,
847 console_origin: "https://gunnar.example".into(),
848 updated: 1,
849 updated_by: account.to_owned(),
850 }
851 }
852
853 #[test]
858 fn sso_maps_are_written_only_when_used() {
859 let store = crate::memory_control_store().expect("store");
860 let auth = AuthTable::new(Arc::clone(&store)).expect("table");
861 auth.add_principal(Principal::new("acme", "acme", PrincipalKind::User).unwrap())
862 .expect("account");
863 let keys = stored_keys(&store);
864 let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
865 assert!(
866 unknown.is_empty(),
867 "a table without SSO carries keys an older gunnar refuses: {unknown:?}"
868 );
869
870 auth.set_sso_provider(provider("acme")).expect("provider");
871 let keys = stored_keys(&store);
872 assert!(
873 keys.contains("sso_providers") && keys.contains("sso_domains"),
874 "a provider is written: {keys:?}"
875 );
876
877 auth.remove_sso_provider("acme").expect("removed");
878 let keys = stored_keys(&store);
879 let unknown: Vec<&String> = keys.iter().filter(|k| !OLDER.contains(&k.as_str())).collect();
880 assert!(unknown.is_empty(), "the provider is gone, its maps must be too: {unknown:?}");
881 }
882}