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_proto::{
15 DirectoryUser, OidcSettings, SamlSettings, ScimToken, SsoProtocol, SsoProvider,
16};
17
18use super::{internal, principal_out, CoreControlPlane};
19
20fn sso_error(err: gunnar_core::Error) -> ControlError {
22 use gunnar_core::Error as E;
23 match err {
24 E::SsoRefused(m) => ControlError::PermissionDenied(m),
25 e @ E::RoomShort { .. } => ControlError::FailedPrecondition(format!(
26 "{e} — the account's admin adds room or users before a new employee can sign in"
27 )),
28 e @ E::NoOpenSeat(_) => ControlError::FailedPrecondition(e.to_string()),
29 e @ E::NameExists { .. } => ControlError::AlreadyExists(e.to_string()),
30 e @ E::KeyExists { .. } => ControlError::AlreadyExists(e.to_string()),
31 E::NoSuchPrincipal(id) => ControlError::NotFound(format!("principal {id}")),
32 e @ E::InvalidInput { .. } => ControlError::InvalidArgument(e.to_string()),
33 other => internal(other),
34 }
35}
36
37fn provider_in(p: &SsoProvider) -> ControlResult<core_sso::SsoProvider> {
38 let protocol = match SsoProtocol::try_from(p.protocol).unwrap_or(SsoProtocol::Unspecified) {
39 SsoProtocol::Oidc => core_sso::SsoProtocol::Oidc,
40 SsoProtocol::Saml => core_sso::SsoProtocol::Saml,
41 SsoProtocol::Unspecified => {
42 return Err(ControlError::InvalidArgument(
43 "`protocol` is OIDC or SAML".into(),
44 ))
45 }
46 };
47 Ok(core_sso::SsoProvider {
48 account: p.account.clone(),
49 protocol,
50 display_name: p.display_name.clone(),
51 domains: p.domains.clone(),
52 oidc: p.oidc.as_ref().map(|o| core_sso::OidcSettings {
53 issuer: o.issuer.clone(),
54 client_id: o.client_id.clone(),
55 scopes: o.scopes.clone(),
56 username_claim: o.username_claim.clone(),
57 display_name_claim: o.display_name_claim.clone(),
58 idp_ca: core_sso::idp_ca_stored(&gunnar_grpc::sso::idp_ca::idp_ca_blocks(&o.idp_ca_pem)),
59 }),
60 saml: p.saml.as_ref().map(|s| core_sso::SamlSettings {
61 idp_entity_id: s.idp_entity_id.clone(),
62 idp_sso_url: s.idp_sso_url.clone(),
63 idp_certificates: s.idp_certificates.clone(),
64 metadata_url: s.idp_metadata_url.clone(),
65 username_attribute: s.username_attribute.clone(),
66 display_name_attribute: s.display_name_attribute.clone(),
67 }),
68 require_sso: p.require_sso,
69 require_passkey: p.require_passkey,
70 console_origin: p.console_origin.clone(),
71 updated: p.updated_at_unix_ms,
72 updated_by: p.updated_by.clone(),
73 })
74}
75
76fn provider_out(p: &core_sso::SsoProvider, has_client_secret: bool) -> SsoProvider {
77 SsoProvider {
78 account: p.account.clone(),
79 protocol: match p.protocol {
80 core_sso::SsoProtocol::Oidc => SsoProtocol::Oidc,
81 core_sso::SsoProtocol::Saml => SsoProtocol::Saml,
82 } as i32,
83 display_name: p.display_name.clone(),
84 domains: p.domains.clone(),
85 oidc: p.oidc.as_ref().map(|o| OidcSettings {
86 issuer: o.issuer.clone(),
87 client_id: o.client_id.clone(),
88 scopes: o.scopes.clone(),
89 username_claim: o.username_claim.clone(),
90 display_name_claim: o.display_name_claim.clone(),
91 idp_ca_pem: gunnar_grpc::sso::idp_ca::idp_ca_pem(&core_sso::idp_ca_from_stored(&o.idp_ca)),
92 idp_ca_fingerprints: Vec::new(),
93 }),
94 saml: p.saml.as_ref().map(|s| SamlSettings {
95 idp_metadata_xml: String::new(),
96 idp_metadata_url: s.metadata_url.clone(),
97 idp_entity_id: s.idp_entity_id.clone(),
98 idp_sso_url: s.idp_sso_url.clone(),
99 idp_certificates: s.idp_certificates.clone(),
100 username_attribute: s.username_attribute.clone(),
101 display_name_attribute: s.display_name_attribute.clone(),
102 }),
103 require_sso: p.require_sso,
104 require_passkey: p.require_passkey,
105 console_origin: p.console_origin.clone(),
106 has_client_secret,
107 updated_at_unix_ms: p.updated,
108 updated_by: p.updated_by.clone(),
109 }
110}
111
112fn user_out(u: &core_sso::SsoUser) -> DirUser {
113 DirUser {
114 user: DirectoryUser {
115 principal_id: u.principal_id.clone(),
116 user_name: u.user_name.clone(),
117 display_name: u.display_name.clone(),
118 email: u.email.clone(),
119 active: u.active,
120 source: u.source.as_str().to_owned(),
121 bound: u.subject.is_some(),
122 created_at_unix_ms: u.created,
123 last_login_unix_ms: u.last_login.unwrap_or(0),
124 external_id: u.external_id.clone(),
125 },
126 deleted: u.deleted,
127 }
128}
129
130fn token_out(t: &core_sso::ScimToken) -> ScimToken {
131 ScimToken {
132 token_id: t.id.clone(),
133 account: t.account.clone(),
134 label: t.label.clone(),
135 created_at_unix_ms: t.created,
136 created_by: t.created_by.clone(),
137 last_used_unix_ms: t.last_used.unwrap_or(0),
138 }
139}
140
141fn group_out(g: &core_sso::SsoGroup) -> GroupRow {
142 GroupRow {
143 id: g.id.clone(),
144 account: g.account.clone(),
145 display_name: g.display_name.clone(),
146 external_id: g.external_id.clone(),
147 members: g.members.clone(),
148 created_at_unix_ms: g.created,
149 }
150}
151
152fn room_ask(room: &gunnar_grpc::sso::plane::Room) -> gunnar_core::RoomAsk {
153 gunnar_core::RoomAsk {
154 room_bytes: room.room_bytes,
155 cap: room.cap,
156 stored: room.stored,
157 ..gunnar_core::RoomAsk::default()
158 }
159}
160
161impl CoreControlPlane {
162 fn sso_candidates(
166 &self,
167 account: &str,
168 user_name: &str,
169 ) -> ControlResult<Vec<gunnar_core::Principal>> {
170 let mut out = Vec::new();
171 for id in core_sso::principal_id_candidates(account, user_name) {
172 if gunnar_grpc::service::access::is_reserved_principal_name(&id) {
173 continue;
174 }
175 if self
176 .stores
177 .catalog()
178 .namespace(&id)
179 .map_err(internal)?
180 .is_some()
181 {
182 continue;
183 }
184 out.push(
185 gunnar_core::Principal::new(&id, &id, gunnar_core::PrincipalKind::User)
186 .map_err(internal)?,
187 );
188 }
189 Ok(out)
190 }
191
192 fn client_secret_stored(&self, account: &str) -> ControlResult<bool> {
193 let secrets = self.stores.secrets().map_err(|err| {
194 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
195 })?;
196 Ok(
197 gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
198 .map_err(|err| {
199 ControlError::Internal(format!("the client secret could not be read: {err}"))
200 })?
201 .is_some(),
202 )
203 }
204}
205
206impl SsoPlane for CoreControlPlane {
207 fn provider(&self, account: &str) -> ControlResult<Option<SsoProvider>> {
208 match self.stores.auth().sso_provider(account).map_err(internal)? {
209 Some(p) => Ok(Some(provider_out(&p, self.client_secret_stored(account)?))),
210 None => Ok(None),
211 }
212 }
213
214 fn any_provider(&self) -> ControlResult<bool> {
215 Ok(!self.stores.auth().sso_providers().map_err(internal)?.is_empty())
216 }
217
218 fn set_provider(
219 &self,
220 provider: SsoProvider,
221 secret: SecretChange,
222 actor: &str,
223 ) -> ControlResult<SsoProvider> {
224 let mut row = provider_in(&provider)?;
225 row.updated_by = actor.to_owned();
226 if row.updated == 0 {
227 row.updated = gunnar_core::now_ms();
228 }
229 let account = row.account.clone();
230 let saved = self
231 .stores
232 .auth()
233 .set_sso_provider(row)
234 .map_err(sso_error)?;
235 let key = core_sso::client_secret_key(&account);
236 let secrets = self.stores.secrets().map_err(|err| {
237 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
238 })?;
239 match secret {
240 SecretChange::Keep => {}
241 SecretChange::Clear => {
242 gunnar_store::SecretStore::delete(&secrets, &key).map_err(|err| {
243 ControlError::Internal(format!("the client secret could not be removed: {err}"))
244 })?;
245 }
246 SecretChange::Set(value) => {
247 gunnar_store::SecretStore::put(&secrets, &key, value.as_bytes()).map_err(
249 |err| {
250 ControlError::Internal(format!(
251 "the client secret could not be sealed: {err}"
252 ))
253 },
254 )?;
255 }
256 }
257 self.stores.mirror_identity();
258 Ok(provider_out(&saved, self.client_secret_stored(&account)?))
259 }
260
261 fn remove_provider(&self, account: &str) -> ControlResult<bool> {
262 let removed = self
263 .stores
264 .auth()
265 .remove_sso_provider(account)
266 .map_err(internal)?
267 .is_some();
268 if let Ok(secrets) = self.stores.secrets() {
269 let _ =
270 gunnar_store::SecretStore::delete(&secrets, &core_sso::client_secret_key(account));
271 }
272 self.stores.mirror_identity();
273 Ok(removed)
274 }
275
276 fn account_for_domain(&self, domain: &str) -> ControlResult<Option<String>> {
277 self.stores
278 .auth()
279 .sso_account_for_domain(domain)
280 .map_err(internal)
281 }
282
283 fn client_secret(&self, account: &str) -> ControlResult<Option<String>> {
284 let secrets = self.stores.secrets().map_err(|err| {
285 ControlError::Internal(format!("the sealed secret store is not available: {err:#}"))
286 })?;
287 let raw = gunnar_store::SecretStore::get(&secrets, &core_sso::client_secret_key(account))
288 .map_err(|err| {
289 ControlError::Internal(format!("the client secret could not be opened: {err}"))
290 })?;
291 raw.map(|bytes| {
292 String::from_utf8(bytes)
293 .map_err(|_| ControlError::Internal("the stored client secret is not text".into()))
294 })
295 .transpose()
296 }
297
298 fn login(&self, ask: SsoLoginAsk) -> ControlResult<SsoLoginOutcome> {
299 let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
300 let done = self
301 .stores
302 .auth()
303 .sso_login(&gunnar_core::SsoLogin {
304 account: ask.account.clone(),
305 subject: core_sso::subject_key(&ask.issuer, &ask.subject),
306 user_name: ask.user_name.clone(),
307 display_name: ask.display_name.clone(),
308 email: ask.email.clone(),
309 candidates,
310 room: room_ask(&ask.room),
311 session: ask.session.clone(),
312 now: ask.now_unix_ms,
313 })
314 .map_err(sso_error)?;
315 self.stores.mirror_identity();
316 if done.session.is_some() {
320 self.stores.activity().signed_in(&done.principal.id, ask.now_unix_ms);
321 }
322 Ok(SsoLoginOutcome {
323 principal: principal_out(&done.principal),
324 user: user_out(&done.user),
325 created: done.created,
326 claimed: done.claimed,
327 fingerprint: done.session.map(|k| k.fingerprint_sha256),
328 })
329 }
330
331 fn directory(&self, account: &str) -> ControlResult<Vec<DirUser>> {
332 Ok(self
333 .stores
334 .auth()
335 .sso_users_of(account)
336 .map_err(internal)?
337 .iter()
338 .map(user_out)
339 .collect())
340 }
341
342 fn provision(&self, ask: ProvisionAsk) -> ControlResult<DirUser> {
343 if self
344 .stores
345 .catalog()
346 .namespace(&ask.account)
347 .map_err(internal)?
348 .is_none()
349 {
350 return Err(ControlError::NotFound(format!("account {}", ask.account)));
351 }
352 let candidates = self.sso_candidates(&ask.account, &ask.user_name)?;
353 let user = self
354 .stores
355 .auth()
356 .sso_provision(&gunnar_core::SsoProvision {
357 account: ask.account.clone(),
358 user_name: ask.user_name.clone(),
359 display_name: ask.display_name.clone(),
360 email: ask.email.clone(),
361 external_id: ask.external_id.clone(),
362 active: ask.active,
363 source: if ask.source == "csv" {
364 core_sso::SsoSource::Csv
365 } else {
366 core_sso::SsoSource::Scim
367 },
368 candidates,
369 room: room_ask(&ask.room),
370 actor: ask.actor.clone(),
371 now: ask.now_unix_ms,
372 })
373 .map_err(sso_error)?;
374 self.stores.mirror_identity();
375 Ok(user_out(&user))
376 }
377
378 fn update_user(
379 &self,
380 account: &str,
381 principal_id: &str,
382 patch: UserPatch,
383 actor: &str,
384 ) -> ControlResult<(DirUser, bool)> {
385 let (user, changed) = self
386 .stores
387 .auth()
388 .sso_user_update(
389 account,
390 principal_id,
391 &gunnar_core::SsoUserPatch {
392 user_name: patch.user_name,
393 display_name: patch.display_name,
394 email: patch.email,
395 external_id: patch.external_id,
396 active: patch.active,
397 },
398 actor,
399 gunnar_core::now_ms(),
400 )
401 .map_err(sso_error)?;
402 self.stores.mirror_identity();
403 Ok((user_out(&user), changed))
404 }
405
406 fn delete_user(
407 &self,
408 account: &str,
409 principal_id: &str,
410 actor: &str,
411 ) -> ControlResult<DirUser> {
412 let user = self
413 .stores
414 .auth()
415 .sso_user_delete(account, principal_id, actor, gunnar_core::now_ms())
416 .map_err(sso_error)?;
417 self.stores.mirror_identity();
418 Ok(user_out(&user))
419 }
420
421 fn create_scim_token(
422 &self,
423 account: &str,
424 id: &str,
425 label: &str,
426 secret_hash: &str,
427 created_by: &str,
428 ) -> ControlResult<ScimToken> {
429 let token = self
430 .stores
431 .auth()
432 .create_scim_token(core_sso::ScimToken {
433 id: id.to_owned(),
434 account: account.to_owned(),
435 secret_hash: secret_hash.to_owned(),
436 label: label.to_owned(),
437 created: gunnar_core::now_ms(),
438 created_by: created_by.to_owned(),
439 last_used: None,
440 })
441 .map_err(sso_error)?;
442 self.stores.mirror_identity();
443 Ok(token_out(&token))
444 }
445
446 fn scim_tokens(&self, account: &str) -> ControlResult<Vec<ScimToken>> {
447 Ok(self
448 .stores
449 .auth()
450 .scim_tokens_of(account)
451 .map_err(internal)?
452 .iter()
453 .map(token_out)
454 .collect())
455 }
456
457 fn revoke_scim_token(&self, account: &str, id: &str) -> ControlResult<bool> {
458 let revoked = self
459 .stores
460 .auth()
461 .revoke_scim_token(account, id)
462 .map_err(internal)?
463 .is_some();
464 self.stores.mirror_identity();
465 Ok(revoked)
466 }
467
468 fn scim_account(&self, secret_hash: &str) -> ControlResult<Option<String>> {
469 let Some(token) = self
470 .stores
471 .auth()
472 .scim_token_by_hash(secret_hash)
473 .map_err(internal)?
474 else {
475 return Ok(None);
476 };
477 let _ = self
478 .stores
479 .auth()
480 .touch_scim_token(secret_hash, gunnar_core::now_ms());
481 Ok(Some(token.account))
482 }
483
484 fn groups(&self, account: &str) -> ControlResult<Vec<GroupRow>> {
485 Ok(self
486 .stores
487 .auth()
488 .sso_groups_of(account)
489 .map_err(internal)?
490 .iter()
491 .map(group_out)
492 .collect())
493 }
494
495 fn put_group(&self, group: GroupRow) -> ControlResult<GroupRow> {
496 let saved = self
497 .stores
498 .auth()
499 .put_sso_group(core_sso::SsoGroup {
500 id: group.id,
501 account: group.account,
502 display_name: group.display_name,
503 external_id: group.external_id,
504 members: group.members,
505 created: group.created_at_unix_ms,
506 })
507 .map_err(sso_error)?;
508 Ok(group_out(&saved))
509 }
510
511 fn remove_group(&self, account: &str, id: &str) -> ControlResult<bool> {
512 Ok(self
513 .stores
514 .auth()
515 .remove_sso_group(account, id)
516 .map_err(internal)?
517 .is_some())
518 }
519
520 fn passkey_login_refused(&self, principal_id: &str) -> ControlResult<Option<String>> {
521 let Some(account) = self
522 .stores
523 .auth()
524 .employer_of(principal_id)
525 .map_err(internal)?
526 else {
527 return Ok(None);
528 };
529 let Some(provider) = self
530 .stores
531 .auth()
532 .sso_provider(&account)
533 .map_err(internal)?
534 else {
535 return Ok(None);
536 };
537 Ok(provider.require_sso.then(|| {
538 format!(
539 "{principal_id} is an employee of {account}, which signs its people in through its identity provider: \
540 use \"Sign in with {}\"",
541 provider.display_name
542 )
543 }))
544 }
545
546 fn passkeys_of(&self, principal_id: &str) -> ControlResult<Vec<Vec<u8>>> {
547 Ok(self
548 .stores
549 .auth()
550 .web_credentials(Some(principal_id))
551 .map_err(internal)?
552 .iter()
553 .filter_map(|c| gunnar_grpc::webauthn::from_b64url(&c.id))
554 .collect())
555 }
556}