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 fn me(store: &AuthStore, p: &Principal) -> R<Value> {
85 let memberships: Vec<Value> = p
86 .orgs
87 .iter()
88 .map(|(o, r)| json!({"org": o, "role": r}))
89 .collect();
90 let orgs: Vec<OrgId> = if p.platform_admin {
93 store.list_orgs()?
94 } else {
95 p.orgs.iter().map(|(o, _)| o.clone()).collect()
96 };
97 let superadmin = match &p.kind {
100 PrincipalKind::Superadmin { source } => json!({
101 "source": source.label(),
102 "via": source,
103 "account": p.user.id > 0,
104 }),
105 _ => Value::Null,
106 };
107 Ok(json!({
108 "user": p.user,
109 "platform_admin": p.platform_admin,
110 "memberships": memberships,
111 "orgs": orgs,
112 "auth": p.kind,
113 "superadmin": superadmin,
114 }))
115}
116
117pub fn sessions(store: &AuthStore, p: &Principal) -> R<Value> {
120 account_holder(p)?;
121 let current = p.session_id();
122 let list: Vec<Value> = store
123 .list_sessions(p.user.id)?
124 .into_iter()
125 .map(|s| {
126 let mut v = serde_json::to_value(&s).unwrap_or_default();
127 v["current"] = json!(Some(s.id) == current);
128 v
129 })
130 .collect();
131 Ok(json!({"sessions": list}))
132}
133
134pub fn revoke_session(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
135 may_change_accounts(p)?;
136 if !store.revoke_session(p.user.id, id)? {
137 return Err(AuthError::NotFound(format!("session {id}")));
138 }
139 Ok(())
140}
141
142pub fn invite(
147 store: &AuthStore,
148 p: &Principal,
149 org: &OrgId,
150 email: &str,
151 role: Option<Role>,
152 public_url: Option<&str>,
153) -> R<Value> {
154 may_change_accounts(p)?;
155 let role = role.unwrap_or(Role::Member);
156 match p.max_grant(org) {
157 Some(max) if role <= max => {}
158 Some(_) => {
159 return Err(AuthError::Forbidden(format!(
160 "you cannot invite someone as {role} in org {org}"
161 )));
162 }
163 None => {
164 return Err(AuthError::Forbidden(format!(
165 "inviting to org {org} needs owner or admin"
166 )));
167 }
168 }
169 let n = store.create_invitation((p.user.id > 0).then_some(p.user.id), org, email, role)?;
170 Ok(json!({
171 "invitation": n.invitation,
172 "token": n.token,
173 "link": link(public_url, "invite", &n.token),
174 }))
175}
176
177pub fn link(public_url: Option<&str>, page: &str, token: &str) -> Option<String> {
180 public_url.map(|u| format!("{}/{page}#{token}", u.trim_end_matches('/')))
181}
182
183#[derive(Debug, Deserialize)]
187pub struct NewToken {
188 pub name: String,
189 #[serde(default)]
190 pub org: Option<OrgId>,
191 #[serde(default)]
193 pub expires: Option<String>,
194 #[serde(default)]
196 pub scopes: Vec<String>,
197 #[serde(default)]
200 pub superadmin: bool,
201}
202
203pub fn tokens(store: &AuthStore, p: &Principal, org: Option<&OrgId>) -> R<Value> {
206 account_holder(p)?;
207 let list = store.list_api_tokens(p.user.id)?;
208 let pinned = match &p.kind {
209 PrincipalKind::ApiToken { org: Some(o), .. } => Some(o),
210 _ => org,
211 };
212 let list: Vec<_> = match pinned {
213 Some(o) => list
214 .into_iter()
215 .filter(|t| t.org.as_ref() == Some(o))
216 .collect(),
217 None => list,
218 };
219 Ok(json!({"tokens": list}))
220}
221
222pub fn create_token(store: &AuthStore, p: &Principal, b: NewToken) -> R<Value> {
224 if b.superadmin {
227 return Err(AuthError::Forbidden(
228 "superadmin tokens are minted on the host only: isb token create NAME --superadmin"
229 .into(),
230 ));
231 }
232 may_change_accounts(p)?;
233 may_mint_tokens(p)?;
234 if p.user.id <= 0 {
235 return Err(AuthError::Forbidden(
236 "a superadmin without an isb account has no tokens of its own".into(),
237 ));
238 }
239 match &b.org {
241 Some(o) if !(p.platform_admin || p.role_in(o).is_some()) => {
242 return Err(AuthError::Forbidden(format!(
243 "you are not a member of org {o}"
244 )));
245 }
246 None if !p.platform_admin => {
247 return Err(AuthError::Forbidden(
248 "a token without an org needs a platform admin; pass an org".into(),
249 ));
250 }
251 _ => {}
252 }
253 let expires: Option<Duration> = b
254 .expires
255 .filter(|s| !s.trim().is_empty())
256 .map(|s| crate::parse_duration(&s).map_err(AuthError::Invalid))
257 .transpose()?;
258 let t =
259 store.create_api_token_scoped(p.user.id, b.org.as_ref(), &b.name, expires, &b.scopes)?;
260 Ok(json!({"token": t.token, "info": t.info}))
261}
262
263pub fn revoke_token(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
267 may_change_accounts(p)?;
268 let hidden = || AuthError::NotFound(format!("token {id}"));
269 let t = store.api_token(id).map_err(|e| match e {
270 AuthError::NotFound(_) => hidden(),
271 e => e,
272 })?;
273 let mine = t.user_id == p.user.id
274 && match &p.kind {
275 PrincipalKind::ApiToken { org: Some(o), .. } => t.org.as_ref() == Some(o),
276 _ => true,
277 };
278 let org_admin = t.org.as_ref().is_some_and(|o| p.can_manage_members(o));
279 if !(mine || org_admin || p.platform_admin) {
280 return Err(hidden());
281 }
282 store.revoke_api_token(id)?;
283 Ok(())
284}
285
286pub fn ssh_keys(store: &AuthStore, p: &Principal) -> R<Value> {
289 account_holder(p)?;
290 let list = if p.user.id > 0 {
291 store.list_ssh_keys(p.user.id)?
292 } else {
293 Vec::new()
294 };
295 Ok(json!({"ssh_keys": list}))
296}
297
298pub fn add_ssh_key(store: &AuthStore, p: &Principal, key: &str, name: Option<&str>) -> R<Value> {
299 may_change_accounts(p)?;
300 if p.user.id <= 0 {
301 return Err(AuthError::Forbidden(
302 "a superadmin without an isb account has no SSH keys of its own".into(),
303 ));
304 }
305 let k = store.add_ssh_key(p.user.id, key, name.filter(|n| !n.trim().is_empty()))?;
306 Ok(json!({"ssh_key": k}))
307}
308
309pub fn delete_ssh_key(store: &AuthStore, p: &Principal, id: i64) -> R<()> {
310 may_change_accounts(p)?;
311 if !store.delete_ssh_key(p.user.id, id)? {
312 return Err(AuthError::NotFound(format!("SSH key {id}")));
313 }
314 Ok(())
315}
316
317pub fn visible_org(p: &Principal, org: &OrgId) -> R<()> {
321 account_holder(p)?;
322 if p.role_in(org).is_none() && !p.platform_admin {
323 return Err(AuthError::NotFound(format!("org {org}")));
324 }
325 Ok(())
326}
327
328fn manage(p: &Principal, org: &OrgId) -> R<()> {
329 visible_org(p, org)?;
330 if p.can_manage_members(org) {
331 Ok(())
332 } else {
333 Err(AuthError::Forbidden(format!(
334 "managing org {org} needs owner or admin"
335 )))
336 }
337}
338
339pub fn members(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
340 visible_org(p, org)?;
341 let list: Vec<Value> = store
342 .list_members(org)?
343 .into_iter()
344 .map(|(u, r)| {
345 let last = store.last_active(u.id)?;
346 Ok(json!({"user": u, "role": r, "last_active": last}))
347 })
348 .collect::<R<_>>()?;
349 Ok(json!({"members": list}))
350}
351
352pub fn set_role(store: &AuthStore, p: &Principal, org: &OrgId, uid: i64, role: Role) -> R<Value> {
353 manage(p, org)?;
354 may_change_accounts(p)?;
355 check_role_change(store, p, org, uid, role)?;
356 store.set_member(org, uid, role)?;
357 Ok(json!({"user_id": uid, "role": role}))
358}
359
360pub fn remove_member(store: &AuthStore, p: &Principal, org: &OrgId, uid: i64) -> R<()> {
363 visible_org(p, org)?;
364 may_change_accounts(p)?;
365 if uid != p.user.id {
366 manage(p, org)?;
367 check_role_change(store, p, org, uid, Role::Member)?;
368 }
369 if !store.remove_member(org, uid)? {
370 return Err(AuthError::NotFound(format!("member {uid}")));
371 }
372 Ok(())
373}
374
375pub fn invitations(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
376 manage(p, org)?;
377 Ok(json!({"invitations": store.list_invitations(org)?}))
378}
379
380pub fn revoke_invitation(store: &AuthStore, p: &Principal, org: &OrgId, id: i64) -> R<()> {
381 manage(p, org)?;
382 may_change_accounts(p)?;
383 if !store.revoke_invitation(org, id)? {
384 return Err(AuthError::NotFound(format!("invitation {id}")));
385 }
386 Ok(())
387}
388
389#[derive(Debug, Deserialize)]
393#[serde(deny_unknown_fields)]
394pub struct NewAgentIdentity {
395 pub kind: AgentKind,
396 pub subject: String,
397 pub role: Role,
398 #[serde(default)]
399 pub note: Option<String>,
400 #[serde(default)]
402 pub org: Option<String>,
403}
404
405pub fn agent_identities(
408 store: &AuthStore,
409 p: &Principal,
410 org: &OrgId,
411 ways: &AgentWays,
412) -> R<Value> {
413 visible_org(p, org)?;
414 let names = p.can_manage_members(org);
418 let me = p.user.email.as_str();
419 let has = |list: &[String]| list.iter().any(|x| x.eq_ignore_ascii_case(me));
420 let admins: Vec<String> = store
421 .list_users()?
422 .into_iter()
423 .filter(|u| u.platform_admin && !u.disabled)
424 .map(|u| u.email)
425 .collect();
426 let who = |l: &[String]| if names { l.to_vec() } else { Vec::new() };
427 Ok(json!({
428 "identities": store.list_agent_identities(org)?,
429 "available": {
430 "tailnet_listen": ways.tailnet_listen,
431 "access": ways.access,
432 "public_url": ways.public_url,
433 "reach": {
434 "platform_admins": {"count": admins.len(), "who": who(&admins)},
435 "access_superadmins": {"count": ways.superadmin_access.len(), "who": who(&ways.superadmin_access), "you": has(&ways.superadmin_access)},
436 "tailnet_superadmins": {"count": ways.superadmin_tailnet.len(), "who": who(&ways.superadmin_tailnet), "you": has(&ways.superadmin_tailnet)},
437 },
438 },
439 }))
440}
441
442pub fn set_agent_identity(
445 store: &AuthStore,
446 p: &Principal,
447 org: &OrgId,
448 b: &NewAgentIdentity,
449) -> R<Value> {
450 manage(p, org)?;
451 may_change_accounts(p)?;
452 match p.max_grant(org) {
453 Some(max) if b.role <= max => {}
454 _ => {
455 return Err(AuthError::Forbidden(format!(
456 "you cannot map an identity to {} in org {org}",
457 b.role
458 )));
459 }
460 }
461 let by: String = p.user.email.chars().take(100).collect();
462 let i = store.set_agent_identity(
463 org,
464 b.kind,
465 &b.subject,
466 b.role,
467 b.note.as_deref().unwrap_or(""),
468 &by,
469 )?;
470 Ok(json!({"identity": i}))
471}
472
473pub fn remove_agent_identity(store: &AuthStore, p: &Principal, org: &OrgId, id: i64) -> R<()> {
475 manage(p, org)?;
476 may_change_accounts(p)?;
477 if !store.remove_agent_identity(org, id)? {
478 return Err(AuthError::NotFound(format!("agent identity {id}")));
479 }
480 Ok(())
481}
482
483pub fn org_tokens(store: &AuthStore, p: &Principal, org: &OrgId) -> R<Value> {
486 manage(p, org)?;
487 let list: Vec<Value> = store
488 .list_org_api_tokens(org)?
489 .into_iter()
490 .map(|t| {
491 let u = store.user(t.user_id)?;
492 let mut v = serde_json::to_value(&t).unwrap_or_default();
493 v["user"] = json!({"id": u.id, "email": u.email, "name": u.name});
494 Ok(v)
495 })
496 .collect::<R<_>>()?;
497 Ok(json!({"tokens": list}))
498}
499
500fn check_role_change(
503 store: &AuthStore,
504 p: &Principal,
505 org: &OrgId,
506 target: i64,
507 new: Role,
508) -> R<()> {
509 let max = p.max_grant(org).unwrap_or(Role::Member);
510 let current = store
511 .memberships(target)?
512 .into_iter()
513 .find(|m| &m.org == org)
514 .map(|m| m.role);
515 if new > max || current.is_some_and(|c| c > max) {
516 return Err(AuthError::Forbidden(format!(
517 "only an owner can change an owner, or make one, in org {org}"
518 )));
519 }
520 Ok(())
521}
522
523fn platform_admin(p: &Principal) -> R<()> {
526 account_holder(p)?;
527 if p.platform_admin {
528 Ok(())
529 } else {
530 Err(AuthError::Forbidden("this is for platform admins".into()))
531 }
532}
533
534pub fn users(store: &AuthStore, p: &Principal) -> R<Value> {
536 platform_admin(p)?;
537 let list: Vec<Value> = store
538 .list_users()?
539 .into_iter()
540 .map(|u| {
541 let memberships = store.memberships(u.id)?;
542 let last = store.last_active(u.id)?;
543 let mut v = serde_json::to_value(&u).unwrap_or_default();
544 v["memberships"] = json!(memberships);
545 v["last_active"] = json!(last);
546 Ok(v)
547 })
548 .collect::<R<_>>()?;
549 Ok(json!({"users": list}))
550}
551
552#[derive(Debug, Default, Deserialize)]
554#[serde(deny_unknown_fields)]
555pub struct UserChange {
556 #[serde(default)]
557 pub disabled: Option<bool>,
558 #[serde(default)]
559 pub platform_admin: Option<bool>,
560}
561
562pub fn update_user(store: &AuthStore, p: &Principal, id: i64, b: &UserChange) -> R<Value> {
565 platform_admin(p)?;
566 may_change_accounts(p)?;
567 let u = store.user(id)?;
568 let demoting = b.disabled == Some(true) || b.platform_admin == Some(false);
569 if demoting && id == p.user.id {
570 return Err(AuthError::Forbidden(
571 "you cannot disable yourself or drop your own platform admin role; ask another platform admin".into(),
572 ));
573 }
574 if demoting && u.platform_admin && store.other_platform_admins(id)? == 0 {
575 return Err(AuthError::Conflict(format!(
576 "{} is the last enabled platform admin; make someone else one first",
577 u.email
578 )));
579 }
580 if let Some(a) = b.platform_admin {
581 store.set_platform_admin(id, a)?;
582 }
583 if let Some(d) = b.disabled {
584 store.set_disabled(id, d)?;
585 }
586 Ok(json!({"user": store.user(id)?}))
587}
588
589#[cfg(test)]
590#[path = "ops_tests.rs"]
591mod tests;