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