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