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