Skip to main content

isb_daemon/daemon/
superadmin.rs

1//! Who is a superadmin ([`crate::auth::superadmin`]) on this daemon: a
2//! superadmin token, a tailnet identity on `--superadmin-tailnet`, or a
3//! verified Cloudflare Access identity on `--superadmin-access`. Nothing
4//! else grants it. One gate serves the tool endpoints (through the authn
5//! hook) and the identity endpoints (`/api/v1/auth/*`).
6
7use std::sync::Arc;
8
9use serde_json::{Value, json};
10
11use crate::auth::agent_identities::{AgentKind, AgentWays};
12use crate::auth::edge::EdgeIdentity;
13use crate::auth::{AuthStore, Principal, Superadmin, SuperadminSource};
14use crate::error::{Error, Result};
15use crate::server::access::{ASSERTION_HEADER, AccessValidator, Identity};
16use crate::server::http::{Peer, Request};
17use crate::server::tailnet::{Tailnet, host_only};
18use crate::server::{Registry, Tool};
19
20/// `--superadmin-access`: Access emails and service-token client ids.
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct AccessAllowList {
23    pub emails: Vec<String>,
24    pub client_ids: Vec<String>,
25}
26
27impl AccessAllowList {
28    /// Comma-separated; an entry with `@` is an email, else a service
29    /// token's client id. Exact matches only: no wildcards or domains.
30    pub fn parse(list: &str) -> Result<AccessAllowList> {
31        let mut a = AccessAllowList::default();
32        for e in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
33            if e.contains(['*', '?', ' ', '\t']) || e.starts_with('@') || e.ends_with('@') {
34                return Err(Error::invalid(format!(
35                    "--superadmin-access: {e:?}: exact emails or service token client ids only"
36                )));
37            }
38            if e.contains('@') {
39                a.emails.push(e.to_ascii_lowercase());
40            } else {
41                a.client_ids.push(e.to_string());
42            }
43        }
44        if a.emails.is_empty() && a.client_ids.is_empty() {
45            return Err(Error::invalid(
46                "--superadmin-access needs at least one email or service token client id",
47            ));
48        }
49        Ok(a)
50    }
51
52    /// A user by email (case-insensitively); a service token by client id.
53    pub fn admits(&self, id: &Identity) -> bool {
54        match (&id.email, &id.common_name) {
55            (Some(e), _) => self.emails.iter().any(|x| x.eq_ignore_ascii_case(e)),
56            (None, Some(cn)) => self.client_ids.iter().any(|x| x == cn),
57            (None, None) => false,
58        }
59    }
60
61    pub fn entries(&self) -> Vec<String> {
62        self.emails
63            .iter()
64            .chain(&self.client_ids)
65            .cloned()
66            .collect()
67    }
68}
69
70/// What the gate makes of a request.
71pub enum Resolved {
72    /// Not a superadmin credential: judge the request as before.
73    None,
74    /// A superadmin token that is not valid.
75    Refused,
76    Superadmin(Arc<Superadmin>),
77}
78
79pub struct Gate {
80    store: Arc<AuthStore>,
81    tailnet: Option<Tailnet>,
82    /// The tailnet `--listen` addresses (what lets a tailnet peer in at all).
83    tailnet_listens: Vec<String>,
84    /// The validator of the listeners Access guards, and the `Host` names an
85    /// Access agent's request may carry (empty: no public URL, not checked).
86    access_agents: Option<(Arc<AccessValidator>, Vec<String>)>,
87    /// The Access validator of the loopback listeners, the allow list, and
88    /// the `Host` names an Access superadmin's request may carry.
89    access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
90}
91
92impl Gate {
93    pub fn new(
94        store: Arc<AuthStore>,
95        tailnet: Option<Tailnet>,
96        access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
97    ) -> Gate {
98        let access = access.map(|(v, a, hosts)| {
99            let mut hosts: Vec<String> = hosts.iter().map(|h| host_only(h)).collect();
100            hosts.sort();
101            hosts.dedup();
102            (v, a, hosts)
103        });
104        Gate {
105            store,
106            tailnet,
107            tailnet_listens: Vec::new(),
108            access_agents: None,
109            access,
110        }
111    }
112
113    /// Let orgs' tailnet and Access agent identities in
114    /// ([`crate::auth::agent_identities`]): the tailnet `--listen`
115    /// addresses, and Access's validator with the `Host` names to allow.
116    pub fn with_agents(
117        mut self,
118        tailnet_listens: Vec<String>,
119        access: Option<(Arc<AccessValidator>, Vec<String>)>,
120    ) -> Gate {
121        self.tailnet_listens = tailnet_listens;
122        self.access_agents = access.map(|(v, hosts)| {
123            let mut hosts: Vec<String> = hosts.iter().map(|h| host_only(h)).collect();
124            hosts.sort();
125            hosts.dedup();
126            (v, hosts)
127        });
128        self
129    }
130
131    /// Which agent identities can reach this server at all.
132    pub fn agent_ways(&self) -> AgentWays {
133        AgentWays {
134            tailnet_listen: if self.tailnet.is_some() {
135                self.tailnet_listens.clone()
136            } else {
137                Vec::new()
138            },
139            access: self.access_agents.is_some(),
140            public_url: None,
141            superadmin_access: self
142                .access_list()
143                .map(AccessAllowList::entries)
144                .unwrap_or_default(),
145            superadmin_tailnet: self
146                .tailnet
147                .as_ref()
148                .map(|t| t.allow().entries())
149                .unwrap_or_default(),
150        }
151    }
152
153    /// The agent identity behind `req`, if an org maps it: a verified
154    /// Access identity (`id`, else the request's own assertion) on a
155    /// loopback listener Access guards, or a tailnet peer, as tailscaled
156    /// says. A bearer token decides on its own, so it is not asked here.
157    /// Superadmin sources are judged first, by [`Gate::resolve`].
158    pub fn agent(&self, req: &Request, id: Option<&Identity>) -> Option<Principal> {
159        if req.header("authorization").is_some() {
160            return None;
161        }
162        let looked = if matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback()) {
163            let (v, hosts) = self.access_agents.as_ref()?;
164            let verified;
165            let id = match id {
166                Some(id) => id,
167                None => {
168                    let t = req.header(ASSERTION_HEADER)?.trim();
169                    verified = v.validate(t).ok()?;
170                    &verified
171                }
172            };
173            if !hosts.is_empty() {
174                let host = req.header("host").map(host_only).unwrap_or_default();
175                if !hosts.contains(&host) {
176                    eprintln!(
177                        "isb serve: Access agent {} sent Host {host:?}, not one of this server's names; not an agent",
178                        id.name()
179                    );
180                    return None;
181                }
182            }
183            self.store
184                .principal_for_access_agent(id.email.as_deref(), id.common_name.as_deref())
185        } else {
186            let w = self.tailnet.as_ref()?.identify(req)?;
187            self.store.principal_for_tailnet(&w.login, &w.node, &w.tags)
188        };
189        looked.unwrap_or_else(|e| {
190            eprintln!("isb serve: agent identities: {e}");
191            None
192        })
193    }
194
195    /// The person a front door verified ([`crate::auth::edge`]): a verified
196    /// Access user on a loopback listener Access guards, or an untagged
197    /// tailnet peer. Service tokens and tagged nodes are not people. They
198    /// may claim setup unless this front door's superadmin allow list
199    /// exists and leaves them out.
200    pub fn edge(&self, req: &Request, id: Option<&Identity>) -> Option<EdgeIdentity> {
201        if req.header("authorization").is_some() {
202            return None;
203        }
204        if matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback()) {
205            let (v, hosts) = self.access_agents.as_ref()?;
206            let verified;
207            let id = match id {
208                Some(id) => id,
209                None => {
210                    let t = req.header(ASSERTION_HEADER)?.trim();
211                    verified = v.validate(t).ok()?;
212                    &verified
213                }
214            };
215            let email = id.email.as_deref()?.to_ascii_lowercase();
216            if !hosts.is_empty() {
217                let host = req.header("host").map(host_only).unwrap_or_default();
218                if !hosts.contains(&host) {
219                    return None;
220                }
221            }
222            let can_claim = self.access_list().is_none_or(|a| a.admits(id));
223            return Some(EdgeIdentity {
224                kind: AgentKind::Access,
225                subject: id.sub.clone(),
226                name: email.clone(),
227                email: Some(email),
228                node: None,
229                can_claim,
230            });
231        }
232        let t = self.tailnet.as_ref()?;
233        let w = t.identify(req)?;
234        if !w.tags.is_empty() {
235            return None;
236        }
237        let listed = !t.allow().entries().is_empty();
238        Some(EdgeIdentity {
239            kind: AgentKind::Tailnet,
240            subject: w.login.clone(),
241            name: w.login.clone(),
242            email: crate::auth::edge::login_email(&w.login),
243            node: Some(w.node.clone()),
244            can_claim: !listed || t.allow().admits(&w),
245        })
246    }
247
248    /// [`Gate::agent`] and [`Gate::edge`] as the identity endpoints ask them.
249    pub fn agent_fn(self: &Arc<Self>) -> crate::auth::http::AgentFn {
250        let g = self.clone();
251        Arc::new(move |r: &Request| g.agent(r, None))
252    }
253
254    pub fn edge_fn(self: &Arc<Self>) -> crate::auth::edge::EdgeFn {
255        let g = self.clone();
256        Arc::new(move |r: &Request| g.edge(r, None))
257    }
258
259    pub fn tailnet(&self) -> Option<&Tailnet> {
260        self.tailnet.as_ref()
261    }
262
263    pub fn access_list(&self) -> Option<&AccessAllowList> {
264        self.access.as_ref().map(|(_, a, _)| a)
265    }
266
267    /// `id` is the Access identity the listener already verified, if any.
268    /// A bearer superadmin token decides alone; any other bearer token is
269    /// not this gate's. Then Access, then the tailnet.
270    pub fn resolve(&self, req: &Request, id: Option<&Identity>) -> Resolved {
271        if let Some(a) = req.header("authorization") {
272            let token = a
273                .trim()
274                .split_once(' ')
275                .filter(|(s, _)| s.eq_ignore_ascii_case("bearer"))
276                .map(|(_, t)| t.trim());
277            return match token {
278                Some(t) if t.starts_with(crate::auth::secret::TokenKind::Superadmin.prefix()) => {
279                    match self.store.authenticate_superadmin_token(t) {
280                        Ok(Some(info)) => Resolved::Superadmin(Arc::new(Superadmin::synthetic(
281                            SuperadminSource::Token {
282                                id: info.id,
283                                name: info.name,
284                            },
285                        ))),
286                        Ok(None) => Resolved::Refused,
287                        Err(e) => {
288                            eprintln!("isb serve: superadmin token: {e}");
289                            Resolved::Refused
290                        }
291                    }
292                }
293                _ => Resolved::None,
294            };
295        }
296        if let Some(s) = self.access_superadmin(req, id) {
297            return Resolved::Superadmin(s);
298        }
299        if let Some(w) = self.tailnet.as_ref().and_then(|t| t.superadmin(req)) {
300            let source = SuperadminSource::Tailnet {
301                login: w.login.clone(),
302                node: w.node,
303                tags: w.tags.clone(),
304            };
305            let as_user = if w.tags.is_empty() {
306                Some(w.login.as_str())
307            } else {
308                None
309            };
310            return Resolved::Superadmin(Arc::new(self.acting_as(source, as_user)));
311        }
312        Resolved::None
313    }
314
315    /// Only from a verified assertion, on the loopback listeners Access
316    /// guards.
317    fn access_superadmin(&self, req: &Request, id: Option<&Identity>) -> Option<Arc<Superadmin>> {
318        let (v, allow, hosts) = self.access.as_ref()?;
319        let loopback = matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback());
320        if !loopback {
321            return None;
322        }
323        let verified;
324        let id = match id {
325            Some(id) => id,
326            None => {
327                let t = req.header(ASSERTION_HEADER)?.trim();
328                verified = v.validate(t).ok()?;
329                &verified
330            }
331        };
332        if !allow.admits(id) {
333            return None;
334        }
335        let host = req.header("host").map(host_only).unwrap_or_default();
336        if !hosts.contains(&host) {
337            eprintln!(
338                "isb serve: Access superadmin {} sent Host {host:?}, not one of this server's names; not a superadmin",
339                id.name()
340            );
341            return None;
342        }
343        let source = SuperadminSource::Access {
344            name: id.name().to_string(),
345            service_token: id.is_service_token(),
346        };
347        Some(Arc::new(self.acting_as(source, id.email.as_deref())))
348    }
349
350    /// As the enabled isb user with this email, else synthetic.
351    fn acting_as(&self, source: SuperadminSource, email: Option<&str>) -> Superadmin {
352        let user = email
353            .and_then(|e| self.store.user_by_email(e).ok().flatten())
354            .filter(|u| !u.disabled);
355        match user {
356            Some(u) => Superadmin::as_user(source.clone(), u, &self.store)
357                .unwrap_or_else(|_| Superadmin::synthetic(source)),
358            None => Superadmin::synthetic(source),
359        }
360    }
361}
362
363/// Tools for superadmins only (not platform admins): the host itself, and
364/// what reaches further into its kernel (nesting for an org's workspace).
365pub const TOOLS: &[&str] = &[
366    "host_inventory",
367    "host_policy",
368    "superadmin_token_list",
369    "superadmin_token_revoke",
370    "org_nesting",
371];
372
373/// A `--listen` address on the tailnet (it then binds without a tunnel).
374pub fn is_tailnet_listen(addr: &str) -> bool {
375    use std::net::ToSocketAddrs;
376    addr.to_socket_addrs().is_ok_and(|mut a| {
377        a.next()
378            .is_some_and(|a| crate::server::tailnet::is_tailnet_ip(a.ip()))
379    })
380}
381
382/// The public URL's host, for the `Host` checks.
383fn public_host(url: &str) -> Option<String> {
384    let rest = url.trim().split_once("://")?.1;
385    let host = rest.split(['/', '?', '#']).next()?;
386    (!host.is_empty()).then(|| host.to_string())
387}
388
389/// The gate `cfg` describes. Refuses `--superadmin-access` without Access or a
390/// public URL (whose host the Access check needs).
391pub fn gate(
392    cfg: &super::ServeConfig,
393    store: Arc<AuthStore>,
394    access: Option<Arc<AccessValidator>>,
395) -> Result<Gate> {
396    let tailnet_listens: Vec<&String> =
397        cfg.listen.iter().filter(|a| is_tailnet_listen(a)).collect();
398    let public = cfg.public_url.as_deref().and_then(public_host);
399    if cfg.superadmin_tailnet.is_some() && tailnet_listens.is_empty() {
400        eprintln!(
401            "isb serve: WARNING: --superadmin-tailnet without a tailnet --listen address: no tailnet peer can reach this daemon"
402        );
403    }
404    // The tailnet check runs wherever a tailnet address is served: for the
405    // superadmin allow list, and for orgs' agent identities.
406    let tailnet = (cfg.superadmin_tailnet.is_some() || !tailnet_listens.is_empty()).then(|| {
407        let allow = cfg.superadmin_tailnet.clone().unwrap_or_default();
408        let mut hosts: Vec<String> = tailnet_listens.iter().map(|a| a.to_string()).collect();
409        hosts.extend(crate::server::tailnet::self_names());
410        hosts.extend(public.clone());
411        crate::server::tailnet::Tailnet::new(allow, crate::server::tailnet::system_fetcher(), hosts)
412    });
413    let mut access_hosts: Vec<String> = public.iter().cloned().collect();
414    access_hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
415    let agent_access = access.clone().map(|v| {
416        let hosts = if public.is_some() {
417            access_hosts
418        } else {
419            Vec::new()
420        };
421        (v, hosts)
422    });
423    let access = match (&cfg.superadmin_access, access) {
424        (None, _) => None,
425        (Some(_), None) => {
426            return Err(Error::invalid(
427                "--superadmin-access needs Cloudflare Access (CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD): it trusts only a verified assertion",
428            ));
429        }
430        (Some(list), Some(v)) => {
431            let Some(host) = public.clone() else {
432                return Err(Error::invalid(
433                    "--superadmin-access needs --public-url: an Access superadmin's request must name this server's host",
434                ));
435            };
436            let mut hosts = vec![host];
437            hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
438            Some((v, list.clone(), hosts))
439        }
440    };
441    let listens = tailnet_listens.iter().map(|a| a.to_string()).collect();
442    Ok(Gate::new(store, tailnet, access).with_agents(listens, agent_access))
443}
444
445/// What `host_policy` reports of the configuration (never a secret).
446pub fn host_summary(cfg: &super::ServeConfig, gate: &Gate) -> Value {
447    json!({
448        "isb": env!("CARGO_PKG_VERSION"),
449        "listen": cfg.listen,
450        "socket": cfg.socket,
451        "state_dir": cfg.state_dir,
452        "public_url": cfg.public_url,
453        "access": cfg.access.as_ref().map(|(team, aud)| json!({"team_domain": team, "aud": aud})),
454        "allow_unauthenticated": cfg.allow_unauthenticated,
455        "tools": {"allow": cfg.remote_tools.allow, "deny": cfg.remote_tools.deny},
456        "policy": {
457            "allow_privileged": cfg.policy.allow_privileged,
458            "allow_raw": cfg.policy.allow_raw,
459            "bind_roots": cfg.policy.bind_roots,
460            "publish_addresses": cfg.policy.publish_addresses,
461            "any_instance": cfg.policy.any_instance,
462        },
463        "superadmin": {
464            "socket": cfg.socket,
465            "tokens": true,
466            "tailnet": cfg.superadmin_tailnet.as_ref().and(gate.tailnet()).map(|t| t.allow().entries()),
467            "tailnet_hosts": gate.tailnet().map(|t| t.hosts().to_vec()),
468            "access": gate.access_list().map(AccessAllowList::entries),
469        },
470    })
471}
472
473/// Say at start-up which sources grant superadmin.
474pub fn announce(cfg: &super::ServeConfig, gate: &Gate, store: &AuthStore) {
475    let mut v = vec![format!("the unix socket {}", cfg.socket.display())];
476    if !cfg.listen.is_empty() {
477        let n = store.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0);
478        v.push(format!(
479            "superadmin tokens ({n}; minted on this host with `isb token create NAME --superadmin`)"
480        ));
481    }
482    if let Some(t) = cfg.superadmin_tailnet.as_ref().and(gate.tailnet()) {
483        v.push(format!(
484            "tailnet identities {} (Host: {})",
485            t.allow().entries().join(", "),
486            t.hosts().join(", ")
487        ));
488    }
489    if let Some(a) = gate.access_list() {
490        v.push(format!(
491            "Cloudflare Access identities {}",
492            a.entries().join(", ")
493        ));
494    }
495    eprintln!("isb serve: superadmins: {}", v.join("; "));
496}
497
498/// The superadmin-only tools.
499pub(super) fn register(r: &mut Registry, d: Arc<super::Daemon>) -> Result<()> {
500    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
501    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
502    let empty = || json!({"type": "object", "properties": {"org": {"type": "string"}}, "additionalProperties": false});
503    let dd = d.clone();
504    r.register(
505        Tool::new(
506            "host_inventory",
507            "Every incus project and instance on the host, isb's or not: projects with the org each one is (if any); instances with project, type, status, addresses, and isb's labels (stack, owner). Superadmins only.",
508            empty(),
509            move |_a, _c| inventory(&dd),
510        )
511        .title("Host inventory")
512        .annotations(ro.clone()),
513    )?;
514    let dd = d.clone();
515    r.register(
516        Tool::new(
517            "host_policy",
518            "How this daemon serves: listen addresses, Cloudflare Access, the tools remote callers see, what a remote caller's specs may ask for (bind roots, publish addresses, privileged, raw, any instance), and every superadmin source with its allow lists. Superadmins only.",
519            empty(),
520            move |_a, _c| {
521                let mut v = dd.host.clone();
522                v["superadmin"]["token_count"] =
523                    json!(dd.users.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0));
524                Ok(v)
525            },
526        )
527        .title("Host policy")
528        .annotations(ro.clone()),
529    )?;
530    let dd = d.clone();
531    r.register(
532        Tool::new(
533            "superadmin_token_list",
534            "Superadmin tokens: id, name, created, last used, expiry (never the token). They are minted only on the host: isb token create NAME --superadmin. Superadmins only.",
535            empty(),
536            move |_a, _c| Ok(json!({"tokens": dd.users.list_superadmin_tokens()?})),
537        )
538        .title("Superadmin tokens")
539        .annotations(ro),
540    )?;
541    let dd = d;
542    r.register(
543        Tool::new(
544            "superadmin_token_revoke",
545            "Revoke a superadmin token by id; it stops working at once. Superadmins only.",
546            json!({"type": "object", "properties": {"id": {"type": "integer"}, "org": {"type": "string"}}, "required": ["id"], "additionalProperties": false}),
547            move |a, _c| {
548                let id = a
549                    .get("id")
550                    .and_then(Value::as_i64)
551                    .ok_or_else(|| Error::invalid("id: an integer"))?;
552                let t = dd.users.superadmin_token(id)?;
553                dd.users.revoke_superadmin_token(id)?;
554                Ok(json!({"revoked": t}))
555            },
556        )
557        .title("Revoke a superadmin token")
558        .annotations(destructive),
559    )?;
560    Ok(())
561}
562
563fn inventory(d: &super::Daemon) -> Result<Value> {
564    let projects = d.client.get("/1.0/projects?recursion=1")?;
565    let projects: Vec<Value> = projects
566        .as_array()
567        .map(|a| {
568            a.iter()
569                .map(|p| {
570                    let name = p["name"].as_str().unwrap_or("");
571                    json!({
572                        "name": name,
573                        "description": p["description"],
574                        "org": crate::org::OrgId::from_incus_project(name).map(|o| o.to_string()),
575                        "instances": p["used_by"].as_array().map(|u| u.iter().filter(|x| x.as_str().is_some_and(|s| s.starts_with("/1.0/instances/"))).count()).unwrap_or(0),
576                    })
577                })
578                .collect()
579        })
580        .unwrap_or_default();
581    let instances = d
582        .client
583        .get("/1.0/instances?recursion=2&all-projects=true")?;
584    let instances: Vec<Value> = instances
585        .as_array()
586        .map(|a| {
587            a.iter()
588                .map(|i| {
589                    let project = i["project"].as_str().unwrap_or("default");
590                    let cfg = &i["config"];
591                    let label = |k: &str| cfg.get(format!("user.{k}")).cloned().unwrap_or(Value::Null);
592                    let addresses: Vec<String> = i["state"]["network"]
593                        .as_object()
594                        .map(|n| {
595                            n.iter()
596                                .filter(|(k, _)| k.as_str() != "lo")
597                                .flat_map(|(_, v)| v["addresses"].as_array().cloned().unwrap_or_default())
598                                .filter(|a| a["scope"] == "global")
599                                .filter_map(|a| a["address"].as_str().map(String::from))
600                                .collect()
601                        })
602                        .unwrap_or_default();
603                    let stack = label("isb.stack");
604                    let owner = label(super::LABEL_OWNER);
605                    json!({
606                        "name": i["name"],
607                        "project": project,
608                        "org": crate::org::OrgId::from_incus_project(project).map(|o| o.to_string()),
609                        "type": i["type"],
610                        "status": i["status"],
611                        "created_at": i["created_at"],
612                        "image": cfg.get("image.description").cloned().unwrap_or(Value::Null),
613                        "addresses": addresses,
614                        "stack": stack,
615                        "owner": owner,
616                        "managed": !stack.is_null() || !owner.is_null(),
617                    })
618                })
619                .collect()
620        })
621        .unwrap_or_default();
622    Ok(json!({"projects": projects, "instances": instances}))
623}
624
625#[cfg(test)]
626mod tests;