Skip to main content

isb_server/auth/
superadmin.rs

1//! Superadmins: the unix socket's reach (every tool, no remote-spec policy,
2//! any instance) for an HTTP caller. Three sources grant it, and nothing else:
3//!
4//! - a **superadmin token** (`isb_sa_...`), minted only on the host with
5//!   `isb token create NAME --superadmin`, never over HTTP, so a stolen HTTP
6//!   credential cannot mint a durable one;
7//! - a **tailnet identity** on `isb serve --superadmin-tailnet` (the daemon's
8//!   [`crate::server::tailnet`] check), judged from the real socket peer;
9//! - a **Cloudflare Access identity** on `isb serve --superadmin-access`: a
10//!   verified `Cf-Access-Jwt-Assertion` whose email (or service token
11//!   client id) is on the list;
12//! - in a debug build, `ISB_DEV_SUPERADMIN` ([`super::dev`]): any loopback
13//!   request with no credential, for developing isb.
14//!
15//! A superadmin acts as an isb user when its tailnet login, Access email or
16//! dev email is one, else as a synthetic principal (user id 0) named after the source.
17
18use std::time::Duration;
19
20use rusqlite::{OptionalExtension, params};
21use serde::Serialize;
22
23use super::secret::{self, TokenKind};
24use super::{AuthError, AuthResult, AuthStore, Principal, PrincipalKind, TOUCH_EVERY, User};
25
26/// Where a superadmin's power comes from.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum SuperadminSource {
30    Token {
31        id: i64,
32        name: String,
33    },
34    Tailnet {
35        /// The tailnet login (`tagged-devices` for a tagged node).
36        login: String,
37        /// The node's MagicDNS name.
38        node: String,
39        #[serde(default, skip_serializing_if = "Vec::is_empty")]
40        tags: Vec<String>,
41    },
42    Access {
43        /// The email, or a service token's client id.
44        name: String,
45        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
46        service_token: bool,
47    },
48    /// `ISB_DEV_SUPERADMIN` ([`super::dev`]; debug builds): any loopback
49    /// request with no credential.
50    Dev {
51        email: String,
52    },
53}
54
55impl SuperadminSource {
56    /// `token:<name>`, `tailnet:<login>` (a tagged node: `tailnet:<node>`),
57    /// `access:<name>` or `dev:<email>`, as audit rows and `isb.owner` labels name it.
58    pub fn label(&self) -> String {
59        match self {
60            SuperadminSource::Token { name, .. } => format!("token:{name}"),
61            SuperadminSource::Tailnet { login, node, tags } => {
62                if tags.is_empty() {
63                    format!("tailnet:{login}")
64                } else {
65                    format!("tailnet:{node}")
66                }
67            }
68            SuperadminSource::Access { name, .. } => format!("access:{name}"),
69            SuperadminSource::Dev { email } => format!("dev:{email}"),
70        }
71    }
72
73    /// Sent by the browser on its own (a tailnet connection; Access's
74    /// `CF_Authorization` cookie; no credential at all, for dev): writes
75    /// need the CSRF defences.
76    pub fn is_ambient(&self) -> bool {
77        matches!(
78            self,
79            SuperadminSource::Tailnet { .. }
80                | SuperadminSource::Access { .. }
81                | SuperadminSource::Dev { .. }
82        )
83    }
84}
85
86/// A caller with the unix socket's reach.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Superadmin {
89    pub source: SuperadminSource,
90    /// Who it acts as: the isb user its tailnet login names (with every org,
91    /// as a platform admin), or a synthetic principal (`user.id` 0, email the
92    /// source's label). Its kind is [`PrincipalKind::Superadmin`].
93    pub principal: Principal,
94}
95
96impl Superadmin {
97    /// A superadmin with no isb account of its own.
98    pub fn synthetic(source: SuperadminSource) -> Superadmin {
99        let label = source.label();
100        Superadmin {
101            principal: Principal {
102                user: User {
103                    id: 0,
104                    email: label.clone(),
105                    name: label,
106                    platform_admin: true,
107                    created_at: 0,
108                    disabled: false,
109                    has_password: false,
110                },
111                kind: PrincipalKind::Superadmin {
112                    source: source.clone(),
113                },
114                orgs: Vec::new(),
115                platform_admin: true,
116                downscoped: None,
117            },
118            source,
119        }
120    }
121
122    /// Acting as `user` (enabled), with its memberships.
123    pub fn as_user(source: SuperadminSource, user: User, store: &AuthStore) -> AuthResult<Self> {
124        let orgs = store
125            .memberships(user.id)?
126            .into_iter()
127            .map(|m| (m.org, m.role))
128            .collect();
129        Ok(Superadmin {
130            principal: Principal {
131                user,
132                kind: PrincipalKind::Superadmin {
133                    source: source.clone(),
134                },
135                orgs,
136                platform_admin: true,
137                downscoped: None,
138            },
139            source,
140        })
141    }
142
143    /// Has an isb account (sessions, passkeys, tokens of its own).
144    pub fn has_account(&self) -> bool {
145        self.principal.user.id > 0
146    }
147
148    pub fn label(&self) -> String {
149        self.source.label()
150    }
151}
152
153/// A superadmin token's metadata.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155pub struct SuperadminToken {
156    pub id: i64,
157    pub name: String,
158    pub created_at: i64,
159    pub last_used: Option<i64>,
160    pub expires_at: Option<i64>,
161}
162
163#[derive(Debug, Clone)]
164pub struct NewSuperadminToken {
165    pub token: String,
166    pub info: SuperadminToken,
167}
168
169const COLS: &str = "id, name, created_at, last_used, expires_at";
170
171fn row(r: &rusqlite::Row) -> rusqlite::Result<SuperadminToken> {
172    Ok(SuperadminToken {
173        id: r.get(0)?,
174        name: r.get(1)?,
175        created_at: r.get(2)?,
176        last_used: r.get(3)?,
177        expires_at: r.get(4)?,
178    })
179}
180
181impl AuthStore {
182    /// Mint a superadmin token. Only the host CLI calls this (it opens
183    /// `isb.db` as the daemon's own user); no HTTP endpoint or tool does.
184    pub fn create_superadmin_token(
185        &self,
186        name: &str,
187        expires: Option<Duration>,
188    ) -> AuthResult<NewSuperadminToken> {
189        let name = name.trim();
190        if name.is_empty()
191            || name.chars().count() > 64
192            || !name
193                .bytes()
194                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
195        {
196            return Err(AuthError::Invalid(
197                "superadmin token name: 1 to 64 of [A-Za-z0-9._-]".into(),
198            ));
199        }
200        let (token, hash) = secret::new_token(TokenKind::Superadmin)?;
201        let now = self.now();
202        let expires_at = expires.map(|d| now + d.as_secs() as i64);
203        let db = self.db();
204        let r = db.execute(
205            "INSERT INTO superadmin_tokens (token_hash, name, created_at, expires_at)
206             VALUES (?1, ?2, ?3, ?4)",
207            params![hash, name, now, expires_at],
208        );
209        match r {
210            Ok(_) => {}
211            Err(rusqlite::Error::SqliteFailure(e, _))
212                if e.code == rusqlite::ErrorCode::ConstraintViolation =>
213            {
214                return Err(AuthError::Conflict(format!(
215                    "a superadmin token named {name} exists; revoke it or pick another name"
216                )));
217            }
218            Err(e) => return Err(e.into()),
219        }
220        Ok(NewSuperadminToken {
221            token,
222            info: SuperadminToken {
223                id: db.last_insert_rowid(),
224                name: name.to_string(),
225                created_at: now,
226                last_used: None,
227                expires_at,
228            },
229        })
230    }
231
232    /// The token behind `isb_sa_...`, if it is valid and unexpired.
233    pub fn authenticate_superadmin_token(
234        &self,
235        token: &str,
236    ) -> AuthResult<Option<SuperadminToken>> {
237        if !secret::well_formed(token, TokenKind::Superadmin) {
238            return Ok(None);
239        }
240        let hash = secret::hash_token(token);
241        let now = self.now();
242        let found = self
243            .db()
244            .query_row(
245                &format!("SELECT token_hash, {COLS} FROM superadmin_tokens WHERE token_hash = ?1"),
246                [&hash],
247                |r| Ok((r.get::<_, Vec<u8>>(0)?, row_at(r)?)),
248            )
249            .optional()?;
250        let Some((stored, t)) = found else {
251            return Ok(None);
252        };
253        if !secret::ct_eq(&stored, &hash) || t.expires_at.is_some_and(|e| now >= e) {
254            return Ok(None);
255        }
256        if t.last_used.is_none_or(|l| now - l >= TOUCH_EVERY) {
257            self.db().execute(
258                "UPDATE superadmin_tokens SET last_used = ?2 WHERE id = ?1",
259                params![t.id, now],
260            )?;
261        }
262        Ok(Some(t))
263    }
264
265    pub fn list_superadmin_tokens(&self) -> AuthResult<Vec<SuperadminToken>> {
266        let db = self.db();
267        let mut st = db.prepare(&format!("SELECT {COLS} FROM superadmin_tokens ORDER BY id"))?;
268        let rows = st.query_map([], row)?;
269        Ok(rows.collect::<rusqlite::Result<_>>()?)
270    }
271
272    pub fn superadmin_token(&self, id: i64) -> AuthResult<SuperadminToken> {
273        self.db()
274            .query_row(
275                &format!("SELECT {COLS} FROM superadmin_tokens WHERE id = ?1"),
276                [id],
277                row,
278            )
279            .optional()?
280            .ok_or_else(|| AuthError::NotFound(format!("superadmin token {id}")))
281    }
282
283    /// Delete one. True if it existed.
284    pub fn revoke_superadmin_token(&self, id: i64) -> AuthResult<bool> {
285        let n = self
286            .db()
287            .execute("DELETE FROM superadmin_tokens WHERE id = ?1", [id])?;
288        Ok(n > 0)
289    }
290}
291
292/// [`row`] past the hash column.
293fn row_at(r: &rusqlite::Row) -> rusqlite::Result<SuperadminToken> {
294    Ok(SuperadminToken {
295        id: r.get(1)?,
296        name: r.get(2)?,
297        created_at: r.get(3)?,
298        last_used: r.get(4)?,
299        expires_at: r.get(5)?,
300    })
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::auth::AuthConfig;
307
308    #[test]
309    fn tokens_mint_authenticate_expire_and_revoke() {
310        let s = AuthStore::in_memory(AuthConfig::default()).unwrap();
311        let t = s.create_superadmin_token("agent", None).unwrap();
312        assert!(t.token.starts_with("isb_sa_"));
313        let got = s.authenticate_superadmin_token(&t.token).unwrap().unwrap();
314        assert_eq!(got.name, "agent");
315        assert!(got.last_used.is_some() || s.superadmin_token(got.id).unwrap().last_used.is_some());
316        // Names are unique; bad ones refused.
317        assert!(matches!(
318            s.create_superadmin_token("agent", None),
319            Err(AuthError::Conflict(_))
320        ));
321        assert!(s.create_superadmin_token("has space", None).is_err());
322        assert!(s.create_superadmin_token("", None).is_err());
323        // An API token string is not a superadmin token, nor a tampered one.
324        assert!(
325            s.authenticate_superadmin_token(&t.token.replace("isb_sa_", "isb_tok_"))
326                .unwrap()
327                .is_none()
328        );
329        let mut bad = t.token.clone();
330        bad.pop();
331        bad.push(if t.token.ends_with('A') { 'B' } else { 'A' });
332        assert!(s.authenticate_superadmin_token(&bad).unwrap().is_none());
333        // Expired.
334        let e = s
335            .create_superadmin_token("short", Some(Duration::from_secs(0)))
336            .unwrap();
337        assert!(s.authenticate_superadmin_token(&e.token).unwrap().is_none());
338        assert_eq!(s.list_superadmin_tokens().unwrap().len(), 2);
339        assert!(s.revoke_superadmin_token(got.id).unwrap());
340        assert!(!s.revoke_superadmin_token(got.id).unwrap());
341        assert!(s.authenticate_superadmin_token(&t.token).unwrap().is_none());
342    }
343
344    #[test]
345    fn labels_and_synthetic_principals() {
346        let tok = SuperadminSource::Token {
347            id: 1,
348            name: "ci".into(),
349        };
350        assert_eq!(tok.label(), "token:ci");
351        assert!(!tok.is_ambient());
352        let person = SuperadminSource::Tailnet {
353            login: "a@example.com".into(),
354            node: "laptop.tail1.ts.net".into(),
355            tags: vec![],
356        };
357        assert_eq!(person.label(), "tailnet:a@example.com");
358        assert!(person.is_ambient());
359        let tagged = SuperadminSource::Tailnet {
360            login: "tagged-devices".into(),
361            node: "agent-1.tail1.ts.net".into(),
362            tags: vec!["tag:agents".into()],
363        };
364        assert_eq!(tagged.label(), "tailnet:agent-1.tail1.ts.net");
365        let access = SuperadminSource::Access {
366            name: "a@example.com".into(),
367            service_token: false,
368        };
369        assert_eq!(access.label(), "access:a@example.com");
370        assert!(access.is_ambient());
371        let dev = SuperadminSource::Dev {
372            email: "dev@dev.com".into(),
373        };
374        assert_eq!(dev.label(), "dev:dev@dev.com");
375        assert!(dev.is_ambient());
376        let s = Superadmin::synthetic(tagged);
377        assert!(!s.has_account());
378        assert!(s.principal.platform_admin);
379        assert_eq!(s.principal.user.email, "tailnet:agent-1.tail1.ts.net");
380    }
381}