1use gunnar_core::sso as core_sso;
9use gunnar_grpc::seam::{ControlError, ControlResult};
10use gunnar_grpc::sso::plane::{
11 DirUser, GroupRow, ProvisionAsk, SecretChange, SsoLoginAsk, SsoLoginOutcome, SsoPlane,
12 UserPatch,
13};
14use gunnar_grpc::sso::scim::{Bearer, ImportRow, Scope};
15use gunnar_proto::{
16 DirectoryUser, OidcSettings, SamlSettings, ScimToken, SsoProtocol, SsoProvider,
17};
18
19use super::{internal, principal_out, CoreControlPlane};
20
21fn sso_error(err: gunnar_core::Error) -> ControlError {
23 use gunnar_core::Error as E;
24 match err {
25 E::SsoRefused(m) => ControlError::PermissionDenied(m),
26 e @ E::RoomShort { .. } => ControlError::FailedPrecondition(format!(
27 "{e} — the account's admin adds room or users before a new employee can sign in"
28 )),
29 e @ E::NoOpenSeat(_) => ControlError::FailedPrecondition(e.to_string()),
30 e @ E::NameExists { .. } => ControlError::AlreadyExists(e.to_string()),
31 e @ E::KeyExists { .. } => ControlError::AlreadyExists(e.to_string()),
32 E::NoSuchPrincipal(id) => ControlError::NotFound(format!("principal {id}")),
33 e @ E::InvalidInput { .. } => ControlError::InvalidArgument(e.to_string()),
34 other => internal(other),
35 }
36}
37
38fn provider_in(p: &SsoProvider) -> ControlResult<core_sso::SsoProvider> {
39 let protocol = match SsoProtocol::try_from(p.protocol).unwrap_or(SsoProtocol::Unspecified) {
40 SsoProtocol::Oidc => core_sso::SsoProtocol::Oidc,
41 SsoProtocol::Saml => core_sso::SsoProtocol::Saml,
42 SsoProtocol::Unspecified => {
43 return Err(ControlError::InvalidArgument(
44 "`protocol` is OIDC or SAML".into(),
45 ))
46 }
47 };
48 Ok(core_sso::SsoProvider {
49 account: p.account.clone(),
50 protocol,
51 display_name: p.display_name.clone(),
52 domains: p.domains.clone(),
53 oidc: p.oidc.as_ref().map(|o| core_sso::OidcSettings {
54 issuer: o.issuer.clone(),
55 client_id: o.client_id.clone(),
56 scopes: o.scopes.clone(),
57 username_claim: o.username_claim.clone(),
58 display_name_claim: o.display_name_claim.clone(),
59 idp_ca: core_sso::idp_ca_stored(&gunnar_grpc::sso::idp_ca::idp_ca_blocks(
60 &o.idp_ca_pem,
61 )),
62 }),
63 saml: p.saml.as_ref().map(|s| core_sso::SamlSettings {
64 idp_entity_id: s.idp_entity_id.clone(),
65 idp_sso_url: s.idp_sso_url.clone(),
66 idp_certificates: s.idp_certificates.clone(),
67 metadata_url: s.idp_metadata_url.clone(),
68 username_attribute: s.username_attribute.clone(),
69 display_name_attribute: s.display_name_attribute.clone(),
70 }),
71 require_sso: p.require_sso,
72 require_passkey: p.require_passkey,
73 console_origin: p.console_origin.clone(),
74 updated: p.updated_at_unix_ms,
75 updated_by: p.updated_by.clone(),
76 verified_domains: Vec::new(),
78 jit: match gunnar_proto::SsoJit::try_from(p.jit).unwrap_or(gunnar_proto::SsoJit::Off) {
79 gunnar_proto::SsoJit::Open => String::new(),
80 gunnar_proto::SsoJit::Domains => gunnar_grpc::sso::rules::JitPolicy::Domains
81 .as_str()
82 .to_owned(),
83 gunnar_proto::SsoJit::Off => {
84 gunnar_grpc::sso::rules::JitPolicy::Off.as_str().to_owned()
85 }
86 },
87 })
88}
89
90fn provider_out(p: &core_sso::SsoProvider, has_client_secret: bool) -> SsoProvider {
91 SsoProvider {
92 account: p.account.clone(),
93 protocol: match p.protocol {
94 core_sso::SsoProtocol::Oidc => SsoProtocol::Oidc,
95 core_sso::SsoProtocol::Saml => SsoProtocol::Saml,
96 } as i32,
97 display_name: p.display_name.clone(),
98 domains: p.domains.clone(),
99 oidc: p.oidc.as_ref().map(|o| OidcSettings {
100 issuer: o.issuer.clone(),
101 client_id: o.client_id.clone(),
102 scopes: o.scopes.clone(),
103 username_claim: o.username_claim.clone(),
104 display_name_claim: o.display_name_claim.clone(),
105 idp_ca_pem: gunnar_grpc::sso::idp_ca::idp_ca_pem(&core_sso::idp_ca_from_stored(
106 &o.idp_ca,
107 )),
108 idp_ca_fingerprints: Vec::new(),
109 }),
110 saml: p.saml.as_ref().map(|s| SamlSettings {
111 idp_metadata_xml: String::new(),
112 idp_metadata_url: s.metadata_url.clone(),
113 idp_entity_id: s.idp_entity_id.clone(),
114 idp_sso_url: s.idp_sso_url.clone(),
115 idp_certificates: s.idp_certificates.clone(),
116 username_attribute: s.username_attribute.clone(),
117 display_name_attribute: s.display_name_attribute.clone(),
118 }),
119 require_sso: p.require_sso,
120 require_passkey: p.require_passkey,
121 console_origin: p.console_origin.clone(),
122 has_client_secret,
123 updated_at_unix_ms: p.updated,
124 updated_by: p.updated_by.clone(),
125 verified_domains: p.verified_domains.clone(),
126 jit: match gunnar_grpc::sso::rules::JitPolicy::parse(&p.jit) {
127 gunnar_grpc::sso::rules::JitPolicy::Open => gunnar_proto::SsoJit::Open,
128 gunnar_grpc::sso::rules::JitPolicy::Domains => gunnar_proto::SsoJit::Domains,
129 gunnar_grpc::sso::rules::JitPolicy::Off => gunnar_proto::SsoJit::Off,
130 } as i32,
131 }
132}
133
134fn user_out(u: &core_sso::SsoUser) -> DirUser {
135 DirUser {
136 user: DirectoryUser {
137 principal_id: u.principal_id.clone(),
138 user_name: u.user_name.clone(),
139 display_name: u.display_name.clone(),
140 email: u.email.clone(),
141 active: u.active,
142 source: u.source.as_str().to_owned(),
143 bound: u.subject.is_some(),
144 created_at_unix_ms: u.created,
145 last_login_unix_ms: u.last_login.unwrap_or(0),
146 external_id: u.external_id.clone(),
147 rebind: u.rebind,
148 },
149 deleted: u.deleted,
150 }
151}
152
153fn token_out(t: &core_sso::ScimToken) -> ScimToken {
154 ScimToken {
155 token_id: t.id.clone(),
156 account: t.account.clone(),
157 label: t.label.clone(),
158 created_at_unix_ms: t.created,
159 created_by: t.created_by.clone(),
160 last_used_unix_ms: t.last_used.unwrap_or(0),
161 expires_at_unix_ms: t.expires.unwrap_or(0),
162 read_only: t.read_only,
163 attention: String::new(),
164 }
165}
166
167fn group_out(g: &core_sso::SsoGroup) -> GroupRow {
168 GroupRow {
169 id: g.id.clone(),
170 account: g.account.clone(),
171 display_name: g.display_name.clone(),
172 external_id: g.external_id.clone(),
173 members: g.members.clone(),
174 created_at_unix_ms: g.created,
175 }
176}
177
178fn room_ask(room: &gunnar_grpc::sso::plane::Room) -> gunnar_core::RoomAsk {
179 gunnar_core::RoomAsk {
180 room_bytes: room.room_bytes,
181 cap: room.cap,
182 stored: room.stored,
183 ..gunnar_core::RoomAsk::default()
184 }
185}
186
187impl CoreControlPlane {
188 fn sso_candidates(
192 &self,
193 account: &str,
194 user_name: &str,
195 ) -> ControlResult<Vec<gunnar_core::Principal>> {
196 let mut out = Vec::new();
197 for id in core_sso::principal_id_candidates(account, user_name) {
198 if gunnar_grpc::service::access::is_reserved_principal_name(&id) {
199 continue;
200 }
201 if self
202 .stores
203 .catalog()
204 .namespace(&id)
205 .map_err(internal)?
206 .is_some()
207 {
208 continue;
209 }
210 out.push(
211 gunnar_core::Principal::new(&id, &id, gunnar_core::PrincipalKind::User)
212 .map_err(internal)?,
213 );
214 }
215 Ok(out)
216 }
217
218 fn account_exists(&self, account: &str) -> ControlResult<()> {
219 if self
220 .stores
221 .catalog()
222 .namespace(account)
223 .map_err(internal)?
224 .is_none()
225 {
226 return Err(ControlError::NotFound(format!("account {account}")));
227 }
228 Ok(())
229 }
230
231 fn provision_of(&self, ask: &ProvisionAsk) -> ControlResult<gunnar_core::SsoProvision> {
233 Ok(gunnar_core::SsoProvision {
234 account: ask.account.clone(),
235 user_name: ask.user_name.clone(),
236 display_name: ask.display_name.clone(),
237 email: ask.email.clone(),
238 external_id: ask.external_id.clone(),
239 active: ask.active,
240 source: if ask.source == "csv" {
241 core_sso::SsoSource::Csv
242 } else {
243 core_sso::SsoSource::Scim
244 },
245 candidates: self.sso_candidates(&ask.account, &ask.user_name)?,
246 room: room_ask(&ask.room),
247 actor: ask.actor.clone(),
248 now: ask.now_unix_ms,
249 })
250 }
251
252 fn client_secret_stored(&self, account: &str) -> ControlResult<bool> {
253 let secrets = self.stores.secrets().map_err(|err| {
254 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
255 })?;
256 Ok(
257 gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
258 .map_err(|err| {
259 ControlError::Internal(format!("the client secret could not be read: {err}"))
260 })?
261 .is_some(),
262 )
263 }
264}
265
266impl SsoPlane for CoreControlPlane {
267 fn provider(&self, account: &str) -> ControlResult<Option<SsoProvider>> {
268 match self.stores.auth().sso_provider(account).map_err(internal)? {
269 Some(p) => Ok(Some(provider_out(&p, self.client_secret_stored(account)?))),
270 None => Ok(None),
271 }
272 }
273
274 fn any_provider(&self) -> ControlResult<bool> {
275 Ok(!self
276 .stores
277 .auth()
278 .sso_providers()
279 .map_err(internal)?
280 .is_empty())
281 }
282
283 fn set_provider(
284 &self,
285 provider: SsoProvider,
286 secret: SecretChange,
287 actor: &str,
288 ) -> ControlResult<SsoProvider> {
289 let mut row = provider_in(&provider)?;
290 row.updated_by = actor.to_owned();
291 if row.updated == 0 {
292 row.updated = gunnar_core::now_ms();
293 }
294 let account = row.account.clone();
295 let saved = self
296 .stores
297 .auth()
298 .set_sso_provider(row)
299 .map_err(sso_error)?;
300 let key = core_sso::client_secret_key(&account);
301 let secrets = self.stores.secrets().map_err(|err| {
302 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
303 })?;
304 match secret {
305 SecretChange::Keep => {}
306 SecretChange::Clear => {
307 gunnar_store::SecretStore::delete(&secrets, &key).map_err(|err| {
308 ControlError::Internal(format!("the client secret could not be removed: {err}"))
309 })?;
310 }
311 SecretChange::Set(value) => {
312 gunnar_store::SecretStore::put(&secrets, &key, value.as_bytes()).map_err(
314 |err| {
315 ControlError::Internal(format!(
316 "the client secret could not be sealed: {err}"
317 ))
318 },
319 )?;
320 }
321 }
322 self.stores.mirror_identity();
323 Ok(provider_out(&saved, self.client_secret_stored(&account)?))
324 }
325
326 fn remove_provider(&self, account: &str) -> ControlResult<bool> {
327 let removed = self
328 .stores
329 .auth()
330 .remove_sso_provider(account)
331 .map_err(internal)?
332 .is_some();
333 if let Ok(secrets) = self.stores.secrets() {
334 let _ =
335 gunnar_store::SecretStore::delete(&secrets, &core_sso::client_secret_key(account));
336 }
337 self.stores.mirror_identity();
338 Ok(removed)
339 }
340
341 fn account_for_domain(&self, domain: &str) -> ControlResult<Option<String>> {
342 self.stores
343 .auth()
344 .sso_account_for_domain(domain)
345 .map_err(internal)
346 }
347
348 fn client_secret(&self, account: &str) -> ControlResult<Option<String>> {
349 let secrets = self.stores.secrets().map_err(|err| {
350 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
351 })?;
352 let raw = gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
353 .map_err(|err| {
354 ControlError::Internal(format!("the client secret could not be opened: {err}"))
355 })?;
356 raw.map(|bytes| {
357 String::from_utf8(bytes)
358 .map_err(|_| ControlError::Internal("the stored client secret is not text".into()))
359 })
360 .transpose()
361 }
362
363 fn login(&self, ask: SsoLoginAsk) -> ControlResult<SsoLoginOutcome> {
364 let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
365 let done = self
366 .stores
367 .auth()
368 .sso_login(&gunnar_core::SsoLogin {
369 account: ask.account.clone(),
370 subject: core_sso::subject_key(&ask.issuer, &ask.subject),
371 user_name: ask.user_name.clone(),
372 display_name: ask.display_name.clone(),
373 email: ask.email.clone(),
374 email_verified: ask.email_verified,
375 idp_subjects: ask.idp_ids.clone(),
376 candidates,
377 room: room_ask(&ask.room),
378 session: ask.session.clone(),
379 now: ask.now_unix_ms,
380 })
381 .map_err(sso_error)?;
382 self.stores.mirror_identity();
383 if done.session.is_some() {
387 self.stores
388 .activity()
389 .signed_in(&done.principal.id, ask.now_unix_ms);
390 }
391 Ok(SsoLoginOutcome {
392 principal: principal_out(&done.principal),
393 user: user_out(&done.user),
394 created: done.created,
395 claimed: done.claimed,
396 claimed_by: done.claimed_by.unwrap_or_default().to_owned(),
397 fingerprint: done.session.map(|k| k.fingerprint_sha256),
398 })
399 }
400
401 fn directory(&self, account: &str) -> ControlResult<Vec<DirUser>> {
402 Ok(self
403 .stores
404 .auth()
405 .sso_users_of(account)
406 .map_err(internal)?
407 .iter()
408 .map(user_out)
409 .collect())
410 }
411
412 fn provision(&self, ask: ProvisionAsk) -> ControlResult<DirUser> {
413 self.account_exists(&ask.account)?;
414 let provision = self.provision_of(&ask)?;
415 let user = self
416 .stores
417 .auth()
418 .sso_provision(&provision)
419 .map_err(sso_error)?;
420 self.stores.mirror_identity();
421 Ok(user_out(&user))
422 }
423
424 fn import(
425 &self,
426 account: &str,
427 rows: &[(String, ImportRow)],
428 actor: &str,
429 now_unix_ms: i64,
430 commit: bool,
431 ) -> ControlResult<()> {
432 self.account_exists(account)?;
433 let mut table = Vec::with_capacity(rows.len());
434 for (label, row) in rows {
435 let op = match row {
436 ImportRow::Create(ask) => gunnar_core::SsoImportOp::Create(self.provision_of(ask)?),
437 ImportRow::Update {
438 principal_id,
439 patch,
440 } => gunnar_core::SsoImportOp::Update {
441 principal_id: principal_id.clone(),
442 patch: gunnar_core::SsoUserPatch {
443 user_name: patch.user_name.clone(),
444 display_name: patch.display_name.clone(),
445 email: patch.email.clone(),
446 external_id: patch.external_id.clone(),
447 active: patch.active,
448 },
449 },
450 };
451 table.push(gunnar_core::SsoImportRow {
452 label: label.clone(),
453 op,
454 });
455 }
456 self.stores
457 .auth()
458 .sso_import(account, &table, actor, now_unix_ms, commit)
459 .map_err(|(label, err)| match sso_error(err) {
460 ControlError::NotFound(m) => ControlError::NotFound(format!("{label}: {m}")),
461 ControlError::AlreadyExists(m) => {
462 ControlError::AlreadyExists(format!("{label}: {m}"))
463 }
464 ControlError::InvalidArgument(m) => {
465 ControlError::InvalidArgument(format!("{label}: {m}"))
466 }
467 ControlError::FailedPrecondition(m) => {
468 ControlError::FailedPrecondition(format!("{label}: {m}"))
469 }
470 ControlError::PermissionDenied(m) => {
471 ControlError::PermissionDenied(format!("{label}: {m}"))
472 }
473 other => other,
474 })?;
475 if commit {
476 self.stores.mirror_identity();
477 }
478 Ok(())
479 }
480
481 fn update_user(
482 &self,
483 account: &str,
484 principal_id: &str,
485 patch: UserPatch,
486 actor: &str,
487 ) -> ControlResult<(DirUser, bool)> {
488 let (user, changed) = self
489 .stores
490 .auth()
491 .sso_user_update(
492 account,
493 principal_id,
494 &gunnar_core::SsoUserPatch {
495 user_name: patch.user_name,
496 display_name: patch.display_name,
497 email: patch.email,
498 external_id: patch.external_id,
499 active: patch.active,
500 },
501 actor,
502 gunnar_core::now_ms(),
503 )
504 .map_err(sso_error)?;
505 self.stores.mirror_identity();
506 Ok((user_out(&user), changed))
507 }
508
509 fn delete_user(
510 &self,
511 account: &str,
512 principal_id: &str,
513 actor: &str,
514 ) -> ControlResult<DirUser> {
515 let user = self
516 .stores
517 .auth()
518 .sso_user_delete(account, principal_id, actor, gunnar_core::now_ms())
519 .map_err(sso_error)?;
520 self.stores.mirror_identity();
521 Ok(user_out(&user))
522 }
523
524 fn create_scim_token(
525 &self,
526 account: &str,
527 id: &str,
528 label: &str,
529 secret_hash: &str,
530 created_by: &str,
531 expires_at_unix_ms: i64,
532 read_only: bool,
533 ) -> ControlResult<ScimToken> {
534 let token = self
535 .stores
536 .auth()
537 .create_scim_token(core_sso::ScimToken {
538 id: id.to_owned(),
539 account: account.to_owned(),
540 secret_hash: secret_hash.to_owned(),
541 label: label.to_owned(),
542 created: gunnar_core::now_ms(),
543 created_by: created_by.to_owned(),
544 last_used: None,
545 expires: (expires_at_unix_ms > 0).then_some(expires_at_unix_ms),
546 read_only,
547 })
548 .map_err(sso_error)?;
549 self.stores.mirror_identity();
550 Ok(token_out(&token))
551 }
552
553 fn scim_tokens(&self, account: &str) -> ControlResult<Vec<ScimToken>> {
554 Ok(self
555 .stores
556 .auth()
557 .scim_tokens_of(account)
558 .map_err(internal)?
559 .iter()
560 .map(token_out)
561 .collect())
562 }
563
564 fn revoke_scim_token(&self, account: &str, id: &str) -> ControlResult<bool> {
565 let revoked = self
566 .stores
567 .auth()
568 .revoke_scim_token(account, id)
569 .map_err(internal)?
570 .is_some();
571 self.stores.mirror_identity();
572 Ok(revoked)
573 }
574
575 fn scim_account(&self, secret_hash: &str) -> ControlResult<Option<(String, Bearer)>> {
576 let Some(token) = self
577 .stores
578 .auth()
579 .scim_token_by_hash(secret_hash)
580 .map_err(internal)?
581 else {
582 return Ok(None);
583 };
584 let _ = self
585 .stores
586 .auth()
587 .touch_scim_token(secret_hash, gunnar_core::now_ms());
588 let bearer = Bearer {
589 expires_at_unix_ms: token.expires.unwrap_or(0),
590 scope: if token.read_only {
591 Scope::Read
592 } else {
593 Scope::ReadWrite
594 },
595 };
596 Ok(Some((token.account, bearer)))
597 }
598
599 fn groups(&self, account: &str) -> ControlResult<Vec<GroupRow>> {
600 Ok(self
601 .stores
602 .auth()
603 .sso_groups_of(account)
604 .map_err(internal)?
605 .iter()
606 .map(group_out)
607 .collect())
608 }
609
610 fn put_group(&self, group: GroupRow) -> ControlResult<GroupRow> {
611 let saved = self
612 .stores
613 .auth()
614 .put_sso_group(core_sso::SsoGroup {
615 id: group.id,
616 account: group.account,
617 display_name: group.display_name,
618 external_id: group.external_id,
619 members: group.members,
620 created: group.created_at_unix_ms,
621 })
622 .map_err(sso_error)?;
623 Ok(group_out(&saved))
624 }
625
626 fn remove_group(&self, account: &str, id: &str) -> ControlResult<bool> {
627 Ok(self
628 .stores
629 .auth()
630 .remove_sso_group(account, id)
631 .map_err(internal)?
632 .is_some())
633 }
634
635 fn passkey_login_refused(&self, principal_id: &str) -> ControlResult<Option<String>> {
636 let Some(account) = self
637 .stores
638 .auth()
639 .employer_of(principal_id)
640 .map_err(internal)?
641 else {
642 return Ok(None);
643 };
644 let Some(provider) = self
645 .stores
646 .auth()
647 .sso_provider(&account)
648 .map_err(internal)?
649 else {
650 return Ok(None);
651 };
652 Ok(provider.require_sso.then(|| {
653 format!(
654 "{principal_id} is an employee of {account}, which signs its people in through its identity provider: \
655 use \"Sign in with {}\"",
656 provider.display_name
657 )
658 }))
659 }
660
661 fn passkeys_of(&self, principal_id: &str) -> ControlResult<Vec<Vec<u8>>> {
662 Ok(self
663 .stores
664 .auth()
665 .web_credentials(Some(principal_id))
666 .map_err(internal)?
667 .iter()
668 .filter_map(|c| gunnar_grpc::webauthn::from_b64url(&c.id))
669 .collect())
670 }
671
672 fn verify_domain(&self, account: &str, domain: &str) -> ControlResult<SsoProvider> {
673 let saved = self
674 .stores
675 .auth()
676 .verify_sso_domain(account, domain)
677 .map_err(sso_error)?;
678 self.stores.mirror_identity();
679 Ok(provider_out(&saved, self.client_secret_stored(account)?))
680 }
681
682 fn rebind_users(
683 &self,
684 account: &str,
685 principal_ids: &[String],
686 external_id: &str,
687 ) -> ControlResult<Vec<DirUser>> {
688 let external = Some(external_id).filter(|e| !e.is_empty());
689 let rows = self
690 .stores
691 .auth()
692 .sso_user_rebind(account, principal_ids, external)
693 .map_err(sso_error)?;
694 self.stores.mirror_identity();
695 Ok(rows.iter().map(user_out).collect())
696 }
697}