Skip to main content

isb_server/auth/
ops.rs

1//! Account operations judged against a [`Principal`]: who is in an org,
2//! invitations, API tokens, SSH keys, sessions and users. The identity
3//! endpoints ([`super::http`]) and the daemon's account tools
4//! (`member_list`, `token_create`, ...) both call these, so a rule holds
5//! the same on every surface. Answers are the JSON the endpoints return.
6//!
7//! The rules on top of each role ([`Role::permissions`]):
8//! - Nobody outside an org learns about it: its `members`, `invitations`
9//!   and `tokens` answer `404` to non-members (platform admins excepted).
10//! - Only an owner (or platform admin) touches an owner or makes one.
11//! - A workspace (its `isb_ws_` token) is nobody's account: it reaches
12//!   none of this.
13//! - **A token cannot mint tokens** ([`may_mint_tokens`]): API tokens,
14//!   workspace tokens and superadmin tokens are refused, so revoking a
15//!   leaked token always ends what it could do. New tokens come from a
16//!   browser session, an Access or tailnet identity, or the host CLI.
17
18use 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
31/// Refuse a workspace: account operations are for people and their tokens.
32pub 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
42/// Refuse a token scoped short of `admin` (and a workspace) a change to
43/// accounts, tokens, keys, invitations or members.
44pub 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
55/// May `p` mint an API token? Not with a token of any kind: a token that
56/// could mint another would survive its own revocation through the copy,
57/// and a scope or expiry bound on the copy would not change that.
58pub 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
83/// Who is calling: the user, their orgs, and how they signed in.
84pub 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    // The orgs this caller can open: every org for a platform admin
91    // (unless the credential is an org token), else its memberships.
92    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    // A superadmin: where its power comes from, and whether it has an isb
98    // account (sessions, passkeys and tokens of its own).
99    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
117// ---- sessions ----
118
119pub 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
142// ---- invitations ----
143
144/// Invite `email` to `org` as `role` (default member). The answer carries
145/// the invitation token once, and a link when `public_url` is known.
146pub 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
177/// `<public_url>/<page>#<token>`: the token goes in the fragment, which
178/// browsers never send to a server or put in a Referer.
179pub 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// ---- API tokens ----
184
185/// What `POST tokens` and `token_create` take.
186#[derive(Debug, Deserialize)]
187pub struct NewToken {
188    pub name: String,
189    #[serde(default)]
190    pub org: Option<OrgId>,
191    /// `90d`, `12h`; absent or null never expires.
192    #[serde(default)]
193    pub expires: Option<String>,
194    /// `read`, `deploy`, `admin`, `tool:GLOB`; empty: the role's reach.
195    #[serde(default)]
196    pub scopes: Vec<String>,
197    /// Never honoured: refused, so nobody mistakes the token they get for
198    /// one.
199    #[serde(default)]
200    pub superadmin: bool,
201}
202
203/// The caller's own tokens (an org token sees only its org's), or only
204/// `org`'s when given.
205pub 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
222/// Mint an API token for the caller: `{token, info}`, the token shown once.
223pub fn create_token(store: &AuthStore, p: &Principal, b: NewToken) -> R<Value> {
224    // Minted on the host only, so a stolen HTTP credential (a superadmin's
225    // included) cannot mint a durable one.
226    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    // Judged by what the caller can reach, not what the user can.
240    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
263/// Revoke token `id`: its holder's own, or any in an org the caller
264/// manages. Anything else is indistinguishable from a token that does not
265/// exist.
266pub 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
286// ---- SSH keys ----
287
288pub 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
317// ---- org administration ----
318
319/// Members see who else is in their org; nobody else learns it exists.
320pub 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
360/// Remove `uid` from `org`: anyone may leave; removing others needs the
361/// right to manage.
362pub 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// ---- agent identities ----
390
391/// What `PUT orgs/{org}/agent-identities` and `agent_identity_set` take.
392#[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    /// Ignored: the org is the endpoint's or the tool's `org`.
401    #[serde(default)]
402    pub org: Option<String>,
403}
404
405/// The org's tailnet and Access mappings, and which front doors this
406/// server has. Any member may read.
407pub fn agent_identities(
408    store: &AuthStore,
409    p: &Principal,
410    org: &OrgId,
411    ways: &AgentWays,
412) -> R<Value> {
413    visible_org(p, org)?;
414    // Who gets in without a mapping: the other half of "can an agent sign
415    // in here". Counts for every member; the names only for those who
416    // manage the org, so a viewer learns nobody's email.
417    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
442/// Map a tailnet login or tag, an Access email or a service token to a
443/// role (never owner, and at most the caller's own) in `org`.
444pub 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
473/// Remove a mapping (owners and admins; a mapping is never an owner's).
474pub 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
483/// Every token in `org`, with who holds each: a platform admin's token in
484/// an org they are not a member of has no member row to name it.
485pub 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
500/// Only an owner (or platform admin) touches an owner or makes one; an
501/// admin manages members and admins.
502fn 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
523// ---- platform administration ----
524
525fn 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
534/// Every user, with their orgs and when they were last active.
535pub 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/// What `PATCH admin/users/ID` and `user_update` change.
553#[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
562/// Disable or enable a user, or make or unmake a platform admin. Nobody
563/// does either to themselves, and the platform keeps an enabled admin.
564pub 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;