1use std::time::Duration;
19
20use serde::Deserialize;
21use serde_json::{Value, json};
22
23#[cfg(test)]
24use super::Superadmin;
25use super::agent_identities::{AgentKind, AgentWays};
26use super::{AuthError, AuthStore, Principal, PrincipalKind, Role, SuperadminSource};
27use crate::org::OrgId;
28
29type R<T> = Result<T, AuthError>;
30
31pub fn account_holder(p: &Principal) -> R<()> {
33 if p.is_workspace() {
34 return Err(AuthError::Forbidden(
35 "a workspace token has no reach into accounts, members, invitations, tokens or keys"
36 .into(),
37 ));
38 }
39 Ok(())
40}
41
42pub fn may_change_accounts(p: &Principal) -> R<()> {
45 account_holder(p)?;
46 if p.restricted() {
47 return Err(AuthError::Forbidden(
48 "this token's scopes do not cover changing accounts, tokens or members (it needs admin)"
49 .into(),
50 ));
51 }
52 Ok(())
53}
54
55pub fn may_mint_tokens(p: &Principal) -> R<()> {
59 if p.is_agent() {
60 return Err(AuthError::Forbidden(
61 "a tailnet or Access agent identity has no tokens of its own: create one from a \
62 signed-in browser session, or on the host with `isb token create`"
63 .into(),
64 ));
65 }
66 let by_token = match &p.kind {
67 PrincipalKind::ApiToken { .. } | PrincipalKind::Workspace { .. } => true,
68 PrincipalKind::Superadmin { source } => matches!(source, SuperadminSource::Token { .. }),
69 PrincipalKind::Session { .. } | PrincipalKind::Access | PrincipalKind::Agent { .. } => {
70 false
71 }
72 };
73 if by_token {
74 return Err(AuthError::Forbidden(
75 "a token cannot mint tokens: create one from a signed-in browser session (Account, \
76 API tokens), or on the host with `isb token create`"
77 .into(),
78 ));
79 }
80 Ok(())
81}
82
83pub type OrgsFn = std::sync::Arc<dyn Fn() -> Result<Vec<OrgId>, String> + Send + Sync>;
88
89pub fn me(store: &AuthStore, p: &Principal, existing: Option<&OrgsFn>) -> R<Value> {
97 let real: Option<Vec<OrgId>> = existing.and_then(|f| match f() {
98 Ok(v) => Some(v),
99 Err(e) => {
100 eprintln!("isb serve: whoami: listing orgs: {e}; using the identity store's");
101 None
102 }
103 });
104 let exists = |o: &OrgId| real.as_ref().is_none_or(|r| r.contains(o));
105 let memberships: Vec<Value> = p
106 .orgs
107 .iter()
108 .filter(|(o, _)| exists(o))
109 .map(|(o, r)| json!({"org": o, "role": r}))
110 .collect();
111 let mut orgs: Vec<OrgId> = if p.platform_admin {
112 match &real {
113 Some(r) => r.clone(),
114 None => store.list_orgs()?,
115 }
116 } else {
117 p.orgs
118 .iter()
119 .map(|(o, _)| o.clone())
120 .filter(|o| exists(o))
121 .collect()
122 };
123 orgs.sort();
124 orgs.dedup();
125 let superadmin = match &p.kind {
128 PrincipalKind::Superadmin { source } => json!({
129 "source": source.label(),
130 "via": source,
131 "account": p.user.id > 0,
132 }),
133 _ => Value::Null,
134 };
135 Ok(json!({
136 "user": p.user,
137 "platform_admin": p.platform_admin,
138 "memberships": memberships,
139 "orgs": orgs,
140 "auth": p.kind,
141 "superadmin": superadmin,
142 }))
143}
144
145pub fn sessions(store: &AuthStore, p: &Principal) -> R<Value> {
148 account_holder(p)?;
149 let current = p.session_id();
150 let list: Vec<Value> = store
151 .list_sessions(p.user.id)?
152 .into_iter()
153 .map(|s| {
154 let mut v = serde_json::to_value(&s).unwrap_or_default();
155 v["current"] = json!(Some(s.id) == current);
156 v
157 })
158 .collect();
159 Ok(json!({"sessions": list}))
160}
161
162pub fn revoke_session(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
163 may_change_accounts(p)?;
164 if !store.revoke_session(p.user.id, id)? {
165 return Err(AuthError::NotFound(format!("session {id}")));
166 }
167 Ok(())
168}
169
170pub fn invite(
175 store: &AuthStore,
176 p: &Principal,
177 org: &OrgId,
178 email: &str,
179 role: Option<Role>,
180 public_url: Option<&str>,
181) -> R<Value> {
182 may_change_accounts(p)?;
183 let role = role.unwrap_or(Role::Member);
184 match p.max_grant(org) {
185 Some(max) if role <= max => {}
186 Some(_) => {
187 return Err(AuthError::Forbidden(format!(
188 "you cannot invite someone as {role} in org {org}"
189 )));
190 }
191 None => {
192 return Err(AuthError::Forbidden(format!(
193 "inviting to org {org} needs owner or admin"
194 )));
195 }
196 }
197 let n = store.create_invitation((p.user.id > 0).then_some(p.user.id), org, email, role)?;
198 Ok(json!({
199 "invitation": n.invitation,
200 "token": n.token,
201 "link": link(public_url, "invite", &n.token),
202 }))
203}
204
205pub fn link(public_url: Option<&str>, page: &str, token: &str) -> Option<String> {
208 public_url.map(|u| format!("{}/{page}#{token}", u.trim_end_matches('/')))
209}
210
211#[derive(Debug, Deserialize)]
215pub struct NewToken {
216 pub name: String,
217 #[serde(default)]
218 pub org: Option<OrgId>,
219 #[serde(default)]
221 pub expires: Option<String>,
222 #[serde(default)]
224 pub scopes: Vec<String>,
225 #[serde(default)]
228 pub superadmin: bool,
229}
230
231pub fn tokens(store: &AuthStore, p: &Principal, org: Option<&OrgId>) -> R<Value> {
234 account_holder(p)?;
235 let list = store.list_api_tokens(p.user.id)?;
236 let pinned = match &p.kind {
237 PrincipalKind::ApiToken { org: Some(o), .. } => Some(o),
238 _ => org,
239 };
240 let list: Vec<_> = match pinned {
241 Some(o) => list
242 .into_iter()
243 .filter(|t| t.org.as_ref() == Some(o))
244 .collect(),
245 None => list,
246 };
247 Ok(json!({"tokens": list}))
248}
249
250pub fn create_token(store: &AuthStore, p: &Principal, b: NewToken) -> R<Value> {
252 if b.superadmin {
255 return Err(AuthError::Forbidden(
256 "superadmin tokens are minted on the host only: isb token create NAME --superadmin"
257 .into(),
258 ));
259 }
260 may_change_accounts(p)?;
261 may_mint_tokens(p)?;
262 if p.user.id <= 0 {
263 return Err(AuthError::Forbidden(
264 "a superadmin without an isb account has no tokens of its own".into(),
265 ));
266 }
267 match &b.org {
269 Some(o) if !(p.platform_admin || p.role_in(o).is_some()) => {
270 return Err(AuthError::Forbidden(format!(
271 "you are not a member of org {o}"
272 )));
273 }
274 None if !p.platform_admin => {
275 return Err(AuthError::Forbidden(
276 "a token without an org needs a platform admin; pass an org".into(),
277 ));
278 }
279 _ => {}
280 }
281 let expires: Option<Duration> = b
282 .expires
283 .filter(|s| !s.trim().is_empty())
284 .map(|s| crate::parse_duration(&s).map_err(AuthError::Invalid))
285 .transpose()?;
286 let t =
287 store.create_api_token_scoped(p.user.id, b.org.as_ref(), &b.name, expires, &b.scopes)?;
288 Ok(json!({"token": t.token, "info": t.info}))
289}
290
291pub fn revoke_token(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
295 may_change_accounts(p)?;
296 let hidden = || AuthError::NotFound(format!("token {id}"));
297 let t = store.api_token(id).map_err(|e| match e {
298 AuthError::NotFound(_) => hidden(),
299 e => e,
300 })?;
301 let mine = t.user_id == p.user.id
302 && match &p.kind {
303 PrincipalKind::ApiToken { org: Some(o), .. } => t.org.as_ref() == Some(o),
304 _ => true,
305 };
306 let org_admin = t.org.as_ref().is_some_and(|o| p.can_manage_members(o));
307 if !(mine || org_admin || p.platform_admin) {
308 return Err(hidden());
309 }
310 store.revoke_api_token(id)?;
311 Ok(())
312}
313
314pub fn ssh_keys(store: &AuthStore, p: &Principal) -> R<Value> {
317 account_holder(p)?;
318 let list = if p.user.id > 0 {
319 store.list_ssh_keys(p.user.id)?
320 } else {
321 Vec::new()
322 };
323 Ok(json!({"ssh_keys": list}))
324}
325
326pub fn add_ssh_key(store: &AuthStore, p: &Principal, key: &str, name: Option<&str>) -> R<Value> {
327 may_change_accounts(p)?;
328 if p.user.id <= 0 {
329 return Err(AuthError::Forbidden(
330 "a superadmin without an isb account has no SSH keys of its own".into(),
331 ));
332 }
333 let k = store.add_ssh_key(p.user.id, key, name.filter(|n| !n.trim().is_empty()))?;
334 Ok(json!({"ssh_key": k}))
335}
336
337pub fn delete_ssh_key(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
338 may_change_accounts(p)?;
339 if !store.delete_ssh_key(p.user.id, id)? {
340 return Err(AuthError::NotFound(format!("SSH key {id}")));
341 }
342 Ok(())
343}
344
345pub fn visible_org(p: &Principal, org: &OrgId) -> R<()> {
349 account_holder(p)?;
350 if p.role_in(org).is_none() && !p.platform_admin {
351 return Err(AuthError::NotFound(format!("org {org}")));
352 }
353 Ok(())
354}
355
356fn manage(p: &Principal, org: &OrgId) -> R<()> {
357 visible_org(p, org)?;
358 if p.can_manage_members(org) {
359 Ok(())
360 } else {
361 Err(AuthError::Forbidden(format!(
362 "managing org {org} needs owner or admin"
363 )))
364 }
365}
366
367pub fn members(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
368 visible_org(p, org)?;
369 let list: Vec<Value> = store
370 .list_members(org)?
371 .into_iter()
372 .map(|(u, r)| {
373 let last = store.last_active(u.id)?;
374 Ok(json!({"user": u, "role": r, "last_active": last}))
375 })
376 .collect::<R<_>>()?;
377 Ok(json!({"members": list}))
378}
379
380pub fn set_role(store: &AuthStore, p: &Principal, org: &OrgId, uid: i64, role: Role) -> R<Value> {
381 manage(p, org)?;
382 may_change_accounts(p)?;
383 check_role_change(store, p, org, uid, role)?;
384 store.set_member(org, uid, role)?;
385 Ok(json!({"user_id": uid, "role": role}))
386}
387
388pub fn remove_member(store: &AuthStore, p: &Principal, org: &OrgId, uid: i64) -> R<()> {
391 visible_org(p, org)?;
392 may_change_accounts(p)?;
393 if uid != p.user.id {
394 manage(p, org)?;
395 check_role_change(store, p, org, uid, Role::Member)?;
396 }
397 if !store.remove_member(org, uid)? {
398 return Err(AuthError::NotFound(format!("member {uid}")));
399 }
400 Ok(())
401}
402
403pub fn invitations(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
404 manage(p, org)?;
405 Ok(json!({"invitations": store.list_invitations(org)?}))
406}
407
408pub fn revoke_invitation(store: &AuthStore, p: &Principal, org: &OrgId, id: i64) -> R<()> {
409 manage(p, org)?;
410 may_change_accounts(p)?;
411 if !store.revoke_invitation(org, id)? {
412 return Err(AuthError::NotFound(format!("invitation {id}")));
413 }
414 Ok(())
415}
416
417#[derive(Debug, Deserialize)]
421#[serde(deny_unknown_fields)]
422pub struct NewAgentIdentity {
423 pub kind: AgentKind,
424 pub subject: String,
425 pub role: Role,
426 #[serde(default)]
427 pub note: Option<String>,
428 #[serde(default)]
430 pub org: Option<String>,
431}
432
433pub fn agent_identities(
436 store: &AuthStore,
437 p: &Principal,
438 org: &OrgId,
439 ways: &AgentWays,
440) -> R<Value> {
441 visible_org(p, org)?;
442 let names = p.can_manage_members(org);
446 let me = p.user.email.as_str();
447 let has = |list: &[String]| list.iter().any(|x| x.eq_ignore_ascii_case(me));
448 let admins: Vec<String> = store
449 .list_users()?
450 .into_iter()
451 .filter(|u| u.platform_admin && !u.disabled)
452 .map(|u| u.email)
453 .collect();
454 let who = |l: &[String]| if names { l.to_vec() } else { Vec::new() };
455 let (mut sa_access, mut sa_tailnet) = (
457 ways.superadmin_access.clone(),
458 ways.superadmin_tailnet.clone(),
459 );
460 for i in store.list_superadmin_identities()? {
461 let list = match i.kind {
462 AgentKind::Access if ways.access && ways.public_url.is_some() => &mut sa_access,
463 AgentKind::Tailnet if !ways.tailnet_listen.is_empty() => &mut sa_tailnet,
464 _ => continue,
465 };
466 if !list.contains(&i.value) {
467 list.push(i.value);
468 }
469 }
470 Ok(json!({
471 "identities": store.list_agent_identities(org)?,
472 "available": {
473 "tailnet_listen": ways.tailnet_listen,
474 "access": ways.access,
475 "public_url": ways.public_url,
476 "reach": {
477 "platform_admins": {"count": admins.len(), "who": who(&admins)},
478 "access_superadmins": {"count": sa_access.len(), "who": who(&sa_access), "you": has(&sa_access)},
479 "tailnet_superadmins": {"count": sa_tailnet.len(), "who": who(&sa_tailnet), "you": has(&sa_tailnet)},
480 },
481 },
482 }))
483}
484
485pub fn set_agent_identity(
488 store: &AuthStore,
489 p: &Principal,
490 org: &OrgId,
491 b: &NewAgentIdentity,
492) -> R<Value> {
493 manage(p, org)?;
494 may_change_accounts(p)?;
495 match p.max_grant(org) {
496 Some(max) if b.role <= max => {}
497 _ => {
498 return Err(AuthError::Forbidden(format!(
499 "you cannot map an identity to {} in org {org}",
500 b.role
501 )));
502 }
503 }
504 let by: String = p.user.email.chars().take(100).collect();
505 let i = store.set_agent_identity(
506 org,
507 b.kind,
508 &b.subject,
509 b.role,
510 b.note.as_deref().unwrap_or(""),
511 &by,
512 )?;
513 Ok(json!({"identity": i}))
514}
515
516pub fn remove_agent_identity(store: &AuthStore, p: &Principal, org: &OrgId, id: i64) -> R<()> {
518 manage(p, org)?;
519 may_change_accounts(p)?;
520 if !store.remove_agent_identity(org, id)? {
521 return Err(AuthError::NotFound(format!("agent identity {id}")));
522 }
523 Ok(())
524}
525
526pub fn org_tokens(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
529 manage(p, org)?;
530 let list: Vec<Value> = store
531 .list_org_api_tokens(org)?
532 .into_iter()
533 .map(|t| {
534 let u = store.user(t.user_id)?;
535 let mut v = serde_json::to_value(&t).unwrap_or_default();
536 v["user"] = json!({"id": u.id, "email": u.email, "name": u.name});
537 Ok(v)
538 })
539 .collect::<R<_>>()?;
540 Ok(json!({"tokens": list}))
541}
542
543fn check_role_change(
546 store: &AuthStore,
547 p: &Principal,
548 org: &OrgId,
549 target: i64,
550 new: Role,
551) -> R<()> {
552 let max = p.max_grant(org).unwrap_or(Role::Member);
553 let current = store
554 .memberships(target)?
555 .into_iter()
556 .find(|m| &m.org == org)
557 .map(|m| m.role);
558 if new > max || current.is_some_and(|c| c > max) {
559 return Err(AuthError::Forbidden(format!(
560 "only an owner can change an owner, or make one, in org {org}"
561 )));
562 }
563 Ok(())
564}
565
566fn platform_admin(p: &Principal) -> R<()> {
569 account_holder(p)?;
570 if p.platform_admin {
571 Ok(())
572 } else {
573 Err(AuthError::Forbidden("this is for platform admins".into()))
574 }
575}
576
577pub fn users(store: &AuthStore, p: &Principal) -> R<Value> {
579 platform_admin(p)?;
580 let list: Vec<Value> = store
581 .list_users()?
582 .into_iter()
583 .map(|u| {
584 let memberships = store.memberships(u.id)?;
585 let last = store.last_active(u.id)?;
586 let mut v = serde_json::to_value(&u).unwrap_or_default();
587 v["memberships"] = json!(memberships);
588 v["last_active"] = json!(last);
589 Ok(v)
590 })
591 .collect::<R<_>>()?;
592 Ok(json!({"users": list}))
593}
594
595#[derive(Debug, Default, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct UserChange {
599 #[serde(default)]
600 pub disabled: Option<bool>,
601 #[serde(default)]
602 pub platform_admin: Option<bool>,
603}
604
605pub fn update_user(store: &AuthStore, p: &Principal, id: i64, b: &UserChange) -> R<Value> {
608 platform_admin(p)?;
609 may_change_accounts(p)?;
610 let u = store.user(id)?;
611 let demoting = b.disabled == Some(true) || b.platform_admin == Some(false);
612 if demoting && id == p.user.id {
613 return Err(AuthError::Forbidden(
614 "you cannot disable yourself or drop your own platform admin role; ask another platform admin".into(),
615 ));
616 }
617 if demoting && u.platform_admin && store.other_platform_admins(id)? == 0 {
618 return Err(AuthError::Conflict(format!(
619 "{} is the last enabled platform admin; make someone else one first",
620 u.email
621 )));
622 }
623 if let Some(a) = b.platform_admin {
624 store.set_platform_admin(id, a)?;
625 }
626 if let Some(d) = b.disabled {
627 store.set_disabled(id, d)?;
628 }
629 Ok(json!({"user": store.user(id)?}))
630}
631
632fn admin_change(p: &Principal) -> R<()> {
640 platform_admin(p)?;
641 may_change_accounts(p)
642}
643
644pub fn all_tokens(store: &AuthStore, p: &Principal) -> R<Value> {
646 platform_admin(p)?;
647 let list: Vec<Value> = store
648 .list_all_api_tokens()?
649 .into_iter()
650 .map(|t| {
651 let u = store.user(t.user_id)?;
652 let mut v = serde_json::to_value(&t).unwrap_or_default();
653 v["user"] = json!({"id": u.id, "email": u.email, "name": u.name});
654 Ok(v)
655 })
656 .collect::<R<_>>()?;
657 Ok(json!({"tokens": list}))
658}
659
660pub fn user_ssh_keys(store: &AuthStore, p: &Principal, id: i64) -> R<Value> {
661 platform_admin(p)?;
662 store.user(id)?;
663 Ok(json!({"ssh_keys": store.list_ssh_keys(id)?}))
664}
665
666pub fn delete_user_ssh_key(store: &AuthStore, p: &Principal, id: i64, key: i64) -> R<()> {
667 admin_change(p)?;
668 if !store.delete_ssh_key(id, key)? {
669 return Err(AuthError::NotFound(format!("SSH key {key}")));
670 }
671 Ok(())
672}
673
674#[cfg(test)]
675#[path = "ops_tests.rs"]
676mod tests;