Skip to main content

isb_server/auth/superadmin/
identities.rs

1//! Superadmin identities kept in `isb.db`: tailnet logins and tags, Access
2//! emails and service token client ids, alongside the `--superadmin-tailnet`
3//! and `--superadmin-access` flags (the bootstrap). The daemon reads the
4//! table on each request that could match, so `isb superadmin add` and `rm`
5//! take effect without a restart.
6//!
7//! Only the host CLI writes it (it opens `isb.db` as the daemon's own user,
8//! as `isb token create --superadmin` does); no HTTP endpoint or tool does,
9//! so an HTTP credential cannot make itself, or anyone, a durable superadmin.
10//! Values are normalized as the flags parse them
11//! ([`super::super::agent_identities::normalize_subject`]): logins, tags and
12//! emails lowercase, a service token's client id exact.
13
14use rusqlite::{OptionalExtension, params};
15use serde::Serialize;
16
17use super::super::agent_identities::{AgentKind, normalize_subject};
18use super::super::{AuthError, AuthResult, AuthStore};
19
20/// How many the table may hold: it is read on every request that could
21/// match one.
22pub const MAX_SUPERADMIN_IDENTITIES: i64 = 200;
23
24/// One identity in the table.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct SuperadminIdentity {
27    pub id: i64,
28    /// `tailnet` (a login or `tag:name`) or `access` (an email, or a
29    /// service token's client id).
30    pub kind: AgentKind,
31    pub value: String,
32    pub added_at: i64,
33    /// Who added it (the CLI's audit name, `local(uid N)`).
34    pub added_by: String,
35}
36
37impl SuperadminIdentity {
38    /// A tailnet identity: whether it admits this login (an untagged node)
39    /// or one of these tags (a tagged node, which never matches by login).
40    pub fn admits_tailnet(&self, login: &str, tags: &[String]) -> bool {
41        if self.kind != AgentKind::Tailnet {
42            return false;
43        }
44        if tags.is_empty() {
45            !self.value.starts_with("tag:") && self.value.eq_ignore_ascii_case(login)
46        } else {
47            tags.iter().any(|t| t.eq_ignore_ascii_case(&self.value))
48        }
49    }
50
51    /// An Access identity: a user by email (case-insensitively), a service
52    /// token by client id (exactly). An id never matches as an email.
53    pub fn admits_access(&self, email: Option<&str>, client_id: Option<&str>) -> bool {
54        if self.kind != AgentKind::Access {
55            return false;
56        }
57        match (email, client_id) {
58            (Some(e), _) => self.value.contains('@') && self.value.eq_ignore_ascii_case(e),
59            (None, Some(cn)) => !self.value.contains('@') && self.value == cn,
60            (None, None) => false,
61        }
62    }
63}
64
65fn row(r: &rusqlite::Row) -> rusqlite::Result<SuperadminIdentity> {
66    let kind: String = r.get(1)?;
67    Ok(SuperadminIdentity {
68        id: r.get(0)?,
69        kind: AgentKind::parse(&kind).map_err(|e| {
70            rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
71        })?,
72        value: r.get(2)?,
73        added_at: r.get(3)?,
74        added_by: r.get(4)?,
75    })
76}
77
78const COLS: &str = "id, kind, value, added_at, added_by";
79
80impl AuthStore {
81    /// Add one. Only the host CLI calls this. A duplicate is refused.
82    pub fn add_superadmin_identity(
83        &self,
84        kind: AgentKind,
85        value: &str,
86        added_by: &str,
87    ) -> AuthResult<SuperadminIdentity> {
88        let value = normalize_subject(kind, value)?;
89        let db = self.db();
90        let n: i64 = db.query_row("SELECT COUNT(*) FROM superadmin_identities", [], |r| {
91            r.get(0)
92        })?;
93        if n >= MAX_SUPERADMIN_IDENTITIES {
94            return Err(AuthError::Invalid(format!(
95                "{n} superadmin identities, the most isb keeps: remove one first"
96            )));
97        }
98        let now = self.now();
99        let r = db.execute(
100            "INSERT INTO superadmin_identities (kind, value, added_at, added_by)
101             VALUES (?1, ?2, ?3, ?4)",
102            params![kind.as_str(), value, now, added_by],
103        );
104        match r {
105            Ok(_) => {}
106            Err(rusqlite::Error::SqliteFailure(e, _))
107                if e.code == rusqlite::ErrorCode::ConstraintViolation =>
108            {
109                return Err(AuthError::Conflict(format!(
110                    "{} {value} is a superadmin already",
111                    kind.as_str()
112                )));
113            }
114            Err(e) => return Err(e.into()),
115        }
116        Ok(SuperadminIdentity {
117            id: db.last_insert_rowid(),
118            kind,
119            value,
120            added_at: now,
121            added_by: added_by.to_string(),
122        })
123    }
124
125    /// Remove one; what was removed. A flag's entry is not in the table.
126    pub fn remove_superadmin_identity(
127        &self,
128        kind: AgentKind,
129        value: &str,
130    ) -> AuthResult<SuperadminIdentity> {
131        let value = normalize_subject(kind, value)?;
132        let db = self.db();
133        let found = db
134            .query_row(
135                &format!("SELECT {COLS} FROM superadmin_identities WHERE kind = ?1 AND value = ?2"),
136                params![kind.as_str(), value],
137                row,
138            )
139            .optional()?;
140        let Some(found) = found else {
141            return Err(AuthError::NotFound(format!(
142                "{} {value} is not a superadmin in isb.db (one from --superadmin-{} is removed from that flag)",
143                kind.as_str(),
144                kind.as_str()
145            )));
146        };
147        db.execute(
148            "DELETE FROM superadmin_identities WHERE id = ?1",
149            [found.id],
150        )?;
151        Ok(found)
152    }
153
154    pub fn list_superadmin_identities(&self) -> AuthResult<Vec<SuperadminIdentity>> {
155        let db = self.db();
156        let mut st = db.prepare(&format!(
157            "SELECT {COLS} FROM superadmin_identities ORDER BY kind, value"
158        ))?;
159        let rows = st.query_map([], row)?;
160        Ok(rows.collect::<rusqlite::Result<_>>()?)
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::auth::AuthConfig;
168
169    #[test]
170    fn add_list_remove_and_validate() {
171        let s = AuthStore::in_memory(AuthConfig::default()).unwrap();
172        let a = s
173            .add_superadmin_identity(AgentKind::Access, " Alice@Example.com ", "local(uid 1)")
174            .unwrap();
175        assert_eq!(
176            (a.value.as_str(), a.added_by.as_str()),
177            ("alice@example.com", "local(uid 1)")
178        );
179        s.add_superadmin_identity(AgentKind::Access, "Svc.Access", "t")
180            .unwrap();
181        s.add_superadmin_identity(AgentKind::Tailnet, "TAG:Agents", "t")
182            .unwrap();
183        s.add_superadmin_identity(AgentKind::Tailnet, "me@example.com", "t")
184            .unwrap();
185        // Duplicates (after normalizing) are refused.
186        assert!(matches!(
187            s.add_superadmin_identity(AgentKind::Access, "alice@EXAMPLE.com", "t"),
188            Err(AuthError::Conflict(_))
189        ));
190        // Exact names only: empty, wildcards, spaces, half an email, a bad tag.
191        for (k, v) in [
192            (AgentKind::Access, ""),
193            (AgentKind::Access, "  "),
194            (AgentKind::Access, "*@example.com"),
195            (AgentKind::Access, "@example.com"),
196            (AgentKind::Access, "a b@example.com"),
197            (AgentKind::Tailnet, "nobody"),
198            (AgentKind::Tailnet, "tag:"),
199            (AgentKind::Tailnet, "tag:a.b"),
200            (AgentKind::Tailnet, "a@x.io,b@x.io"),
201        ] {
202            assert!(
203                matches!(
204                    s.add_superadmin_identity(k, v, "t"),
205                    Err(AuthError::Invalid(_))
206                ),
207                "{k:?} {v:?}"
208            );
209        }
210        let l = s.list_superadmin_identities().unwrap();
211        let got: Vec<_> = l.iter().map(|i| (i.kind, i.value.as_str())).collect();
212        assert_eq!(
213            got,
214            vec![
215                (AgentKind::Access, "Svc.Access"),
216                (AgentKind::Access, "alice@example.com"),
217                (AgentKind::Tailnet, "me@example.com"),
218                (AgentKind::Tailnet, "tag:agents"),
219            ]
220        );
221        // Matching: emails any case, client ids exactly, tags only for
222        // tagged nodes, logins only for untagged ones.
223        let find = |v: &str| l.iter().find(|i| i.value == v).unwrap();
224        assert!(find("alice@example.com").admits_access(Some("ALICE@example.com"), None));
225        assert!(!find("alice@example.com").admits_access(None, Some("alice@example.com")));
226        assert!(find("Svc.Access").admits_access(None, Some("Svc.Access")));
227        assert!(!find("Svc.Access").admits_access(None, Some("svc.access")));
228        assert!(!find("Svc.Access").admits_tailnet("Svc.Access", &[]));
229        assert!(find("tag:agents").admits_tailnet("tagged-devices", &["tag:agents".into()]));
230        assert!(!find("tag:agents").admits_tailnet("tag:agents", &[]));
231        assert!(find("me@example.com").admits_tailnet("Me@example.com", &[]));
232        assert!(!find("me@example.com").admits_tailnet("me@example.com", &["tag:x".into()]));
233        assert!(!find("me@example.com").admits_access(Some("me@example.com"), None));
234        // Remove by the same spelling rules; twice is not found.
235        let r = s
236            .remove_superadmin_identity(AgentKind::Access, "ALICE@example.com")
237            .unwrap();
238        assert_eq!(r.id, a.id);
239        assert!(matches!(
240            s.remove_superadmin_identity(AgentKind::Access, "alice@example.com"),
241            Err(AuthError::NotFound(_))
242        ));
243        assert_eq!(s.list_superadmin_identities().unwrap().len(), 3);
244    }
245}