Skip to main content

isb_server/auth/
agent_identities.rs

1//! Agent identities: tailnet and Cloudflare Access callers an org lets in as
2//! its agents, each mapped to a role in that one org.
3//!
4//! - A **tailnet** mapping names a login (`someone@example.com`) or a node
5//!   tag (`tag:agents`). A tagged node matches only by its tags, never by
6//!   its owner's login; any other node matches by its user's login.
7//! - An **Access** mapping names the email of someone who is not an isb user
8//!   (an isb user's email acts as that user, with their real memberships), or
9//!   a service token's client id.
10//! - The role is `viewer`, `member` or `admin`: never `owner`.
11//!
12//! A matched caller is a synthetic principal ([`Principal::agent`]) with no
13//! account, confined to the orgs that mapped it. Who resolves a request to
14//! an identity (tailscaled's whois, a verified Access assertion) is the
15//! daemon's gate; this module is the table and the matching.
16
17use rusqlite::{OptionalExtension, Row, params};
18use serde::{Deserialize, Serialize};
19
20use super::{AuthError, AuthResult, AuthStore, Principal, Role, clean_name, org_col, role_col};
21use crate::org::OrgId;
22
23/// How many mappings one org may hold.
24pub const MAX_PER_ORG: i64 = 100;
25
26/// Which front door a mapping is for.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AgentKind {
30    Tailnet,
31    Access,
32}
33
34impl AgentKind {
35    pub fn as_str(self) -> &'static str {
36        match self {
37            AgentKind::Tailnet => "tailnet",
38            AgentKind::Access => "access",
39        }
40    }
41
42    pub fn parse(s: &str) -> AuthResult<AgentKind> {
43        match s {
44            "tailnet" => Ok(AgentKind::Tailnet),
45            "access" => Ok(AgentKind::Access),
46            _ => Err(AuthError::Invalid(format!("kind {s:?}: tailnet or access"))),
47        }
48    }
49}
50
51/// Which front doors this server has, so an org can tell whether its
52/// mappings could ever be used.
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
54pub struct AgentWays {
55    /// The tailnet `--listen` addresses (empty: no tailnet peer can reach
56    /// the server).
57    pub tailnet_listen: Vec<String>,
58    /// Cloudflare Access guards a listener.
59    pub access: bool,
60    /// The server's `--public-url` (where Access sits in front of it).
61    pub public_url: Option<String>,
62    /// `--superadmin-access` entries (emails, service token client ids):
63    /// they reach every org through Access with no mapping.
64    #[serde(skip)]
65    pub superadmin_access: Vec<String>,
66    /// `--superadmin-tailnet` entries (logins, tags): they reach every org.
67    #[serde(skip)]
68    pub superadmin_tailnet: Vec<String>,
69}
70
71impl AgentWays {
72    /// The same, with the server's `--public-url`.
73    pub fn with_public_url(mut self, url: Option<String>) -> AgentWays {
74        self.public_url = url;
75        self
76    }
77}
78
79/// One mapping.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct AgentIdentity {
82    pub id: i64,
83    pub org: OrgId,
84    pub kind: AgentKind,
85    /// Tailnet: a login (`a@example.com`) or `tag:name`. Access: an email or
86    /// a service token's client id.
87    pub subject: String,
88    pub role: Role,
89    pub note: String,
90    pub created_at: i64,
91    /// Who made it (an audit-style name).
92    pub created_by: String,
93}
94
95/// The mapping a subject string means, normalized: logins, tags and emails
96/// lowercase; a client id as given. `Err` says what is wrong.
97pub fn normalize_subject(kind: AgentKind, subject: &str) -> AuthResult<String> {
98    let s = subject.trim();
99    let bad = |m: &str| {
100        Err(AuthError::Invalid(format!(
101            "{} identity {s:?}: {m}",
102            kind.as_str()
103        )))
104    };
105    if s.is_empty() || s.len() > 200 {
106        return bad("1 to 200 characters");
107    }
108    if s.contains(['*', '?', ',']) || s.chars().any(|c| c.is_whitespace() || c.is_control()) {
109        return bad("exact names only: no wildcards, spaces or commas");
110    }
111    match kind {
112        AgentKind::Tailnet => {
113            if let Some(t) = s
114                .get(..4)
115                .filter(|p| p.eq_ignore_ascii_case("tag:"))
116                .map(|_| &s[4..])
117            {
118                if t.is_empty()
119                    || !t
120                        .bytes()
121                        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
122                {
123                    return bad("a tag is tag: and letters, digits, - or _");
124                }
125                Ok(format!("tag:{}", t.to_ascii_lowercase()))
126            } else if s.contains('@') && !s.starts_with('@') && !s.ends_with('@') {
127                Ok(s.to_ascii_lowercase())
128            } else {
129                bad("a login name (someone@example.com) or a tag (tag:name)")
130            }
131        }
132        AgentKind::Access => {
133            if s.contains('@') {
134                if s.starts_with('@') || s.ends_with('@') {
135                    return bad("a whole email address");
136                }
137                Ok(s.to_ascii_lowercase())
138            } else {
139                Ok(s.to_string())
140            }
141        }
142    }
143}
144
145fn row(r: &Row) -> rusqlite::Result<AgentIdentity> {
146    let kind: String = r.get(2)?;
147    Ok(AgentIdentity {
148        id: r.get(0)?,
149        org: org_col(r, 1)?,
150        kind: AgentKind::parse(&kind).unwrap_or(AgentKind::Access),
151        subject: r.get(3)?,
152        role: role_col(r, 4)?,
153        note: r.get(5)?,
154        created_at: r.get(6)?,
155        created_by: r.get(7)?,
156    })
157}
158
159const COLS: &str = "id, org, kind, subject, role, note, created_at, created_by";
160
161impl AuthStore {
162    /// An org's mappings, oldest first.
163    pub fn list_agent_identities(&self, org: &OrgId) -> AuthResult<Vec<AgentIdentity>> {
164        let db = self.db();
165        let mut st = db.prepare(&format!(
166            "SELECT {COLS} FROM org_agent_identities WHERE org = ?1 ORDER BY id"
167        ))?;
168        let rows = st.query_map([org.as_str()], row)?;
169        Ok(rows.collect::<rusqlite::Result<_>>()?)
170    }
171
172    /// Map `subject` to `role` in `org`, or change the role of an existing
173    /// mapping. Never `owner`; an Access email that is an isb user's is
174    /// refused (it acts as that user: make them a member).
175    pub fn set_agent_identity(
176        &self,
177        org: &OrgId,
178        kind: AgentKind,
179        subject: &str,
180        role: Role,
181        note: &str,
182        created_by: &str,
183    ) -> AuthResult<AgentIdentity> {
184        if role == Role::Owner {
185            return Err(AuthError::Invalid(
186                "an agent identity is a viewer, member or admin: never an owner".into(),
187            ));
188        }
189        let subject = normalize_subject(kind, subject)?;
190        let note = if note.trim().is_empty() {
191            String::new()
192        } else {
193            clean_name(note)?
194        };
195        if kind == AgentKind::Access
196            && subject.contains('@')
197            && self.user_by_email(&subject)?.is_some()
198        {
199            return Err(AuthError::Conflict(format!(
200                "a Cloudflare Access caller with the email {subject} already acts as the isb user with that address; add that user as a member of the org instead"
201            )));
202        }
203        let db = self.db();
204        let n: i64 = db.query_row(
205            "SELECT COUNT(*) FROM org_agent_identities WHERE org = ?1",
206            [org.as_str()],
207            |r| r.get(0),
208        )?;
209        let exists: Option<i64> = db
210            .query_row(
211                "SELECT id FROM org_agent_identities WHERE org = ?1 AND kind = ?2 AND subject = ?3",
212                params![org.as_str(), kind.as_str(), subject],
213                |r| r.get(0),
214            )
215            .optional()?;
216        let id = match exists {
217            Some(id) => {
218                db.execute(
219                    "UPDATE org_agent_identities SET role = ?2, note = ?3 WHERE id = ?1",
220                    params![id, role.as_str(), note],
221                )?;
222                id
223            }
224            None => {
225                if n >= MAX_PER_ORG {
226                    return Err(AuthError::Invalid(format!(
227                        "an org holds at most {MAX_PER_ORG} agent identities; remove one first"
228                    )));
229                }
230                let r = db.execute(
231                    "INSERT INTO org_agent_identities (org, kind, subject, role, note, created_at, created_by)
232                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
233                    params![
234                        org.as_str(),
235                        kind.as_str(),
236                        subject,
237                        role.as_str(),
238                        note,
239                        self.now(),
240                        created_by
241                    ],
242                );
243                match r {
244                    Ok(_) => db.last_insert_rowid(),
245                    Err(rusqlite::Error::SqliteFailure(e, _))
246                        if e.code == rusqlite::ErrorCode::ConstraintViolation =>
247                    {
248                        return Err(AuthError::NotFound(format!("org {org}")));
249                    }
250                    Err(e) => return Err(e.into()),
251                }
252            }
253        };
254        Ok(db.query_row(
255            &format!("SELECT {COLS} FROM org_agent_identities WHERE id = ?1"),
256            [id],
257            row,
258        )?)
259    }
260
261    /// Remove one of `org`'s mappings. True if it existed.
262    pub fn remove_agent_identity(&self, org: &OrgId, id: i64) -> AuthResult<bool> {
263        let n = self.db().execute(
264            "DELETE FROM org_agent_identities WHERE id = ?1 AND org = ?2",
265            params![id, org.as_str()],
266        )?;
267        Ok(n > 0)
268    }
269
270    /// The orgs that map any of `subjects` (already normalized) for `kind`,
271    /// with the highest role each gives.
272    pub fn agent_orgs(
273        &self,
274        kind: AgentKind,
275        subjects: &[String],
276    ) -> AuthResult<Vec<(OrgId, Role)>> {
277        let mut out: Vec<(OrgId, Role)> = Vec::new();
278        let db = self.db();
279        let mut st = db.prepare(
280            "SELECT org, role FROM org_agent_identities WHERE kind = ?1 AND subject = ?2",
281        )?;
282        for s in subjects {
283            let rows = st.query_map(params![kind.as_str(), s], |r| {
284                Ok((org_col(r, 0)?, role_col(r, 1)?))
285            })?;
286            for r in rows {
287                let (org, role) = r?;
288                // The table never holds an owner; a hand-edited row is capped.
289                let role = role.min(Role::Admin);
290                match out.iter_mut().find(|(o, _)| *o == org) {
291                    Some((_, r0)) => *r0 = (*r0).max(role),
292                    None => out.push((org, role)),
293                }
294            }
295        }
296        out.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
297        Ok(out)
298    }
299
300    /// The principal of a tailnet node: a tagged node by its tags only, any
301    /// other by its user's login. `None` when no org maps it.
302    pub fn principal_for_tailnet(
303        &self,
304        login: &str,
305        node: &str,
306        tags: &[String],
307    ) -> AuthResult<Option<Principal>> {
308        let subjects: Vec<String> = if tags.is_empty() {
309            vec![login.to_ascii_lowercase()]
310        } else {
311            tags.iter().map(|t| t.to_ascii_lowercase()).collect()
312        };
313        let orgs = self.agent_orgs(AgentKind::Tailnet, &subjects)?;
314        if orgs.is_empty() {
315            return Ok(None);
316        }
317        let name = if tags.is_empty() { login } else { node };
318        Ok(Some(Principal::agent(&format!("tailnet:{name}"), orgs)))
319    }
320
321    /// The principal of a verified Access identity that is not an isb user:
322    /// a person's email, else a service token's client id. `None` when no
323    /// org maps it, and for an email that is an isb user's (such a caller
324    /// acts as that user, [`AuthStore::principal_for_email`]).
325    pub fn principal_for_access_agent(
326        &self,
327        email: Option<&str>,
328        client_id: Option<&str>,
329    ) -> AuthResult<Option<Principal>> {
330        let name = match (email, client_id) {
331            (Some(e), _) => {
332                if self.user_by_email(e)?.is_some() {
333                    return Ok(None);
334                }
335                e.to_ascii_lowercase()
336            }
337            (None, Some(c)) => c.to_string(),
338            (None, None) => return Ok(None),
339        };
340        let orgs = self.agent_orgs(AgentKind::Access, std::slice::from_ref(&name))?;
341        if orgs.is_empty() {
342            return Ok(None);
343        }
344        Ok(Some(Principal::agent(&format!("access:{name}"), orgs)))
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::auth::AuthConfig;
352
353    fn store() -> AuthStore {
354        AuthStore::in_memory(AuthConfig {
355            password_cost: crate::auth::secret::PasswordCost::insecure_fast(),
356            ..Default::default()
357        })
358        .unwrap()
359    }
360
361    fn org(s: &AuthStore, name: &str) -> OrgId {
362        let o = OrgId::new(name).unwrap();
363        s.ensure_org(&o).unwrap();
364        o
365    }
366
367    #[test]
368    fn subjects_are_exact_and_normalized() {
369        use AgentKind::*;
370        assert_eq!(
371            normalize_subject(Tailnet, " Me@Example.com ").unwrap(),
372            "me@example.com"
373        );
374        assert_eq!(
375            normalize_subject(Tailnet, "tag:Agents").unwrap(),
376            "tag:agents"
377        );
378        for bad in [
379            "",
380            "tag:",
381            "tag:a b",
382            "*@example.com",
383            "@example.com",
384            "nobody",
385            "tag:a*",
386        ] {
387            assert!(normalize_subject(Tailnet, bad).is_err(), "{bad}");
388        }
389        assert_eq!(
390            normalize_subject(Access, "Bob@Example.com").unwrap(),
391            "bob@example.com"
392        );
393        assert_eq!(
394            normalize_subject(Access, "AbC123.access").unwrap(),
395            "AbC123.access"
396        );
397        assert!(normalize_subject(Access, "a@").is_err());
398        assert!(normalize_subject(Access, "a,b").is_err());
399    }
400
401    #[test]
402    fn mappings_upsert_cap_and_never_own() {
403        let s = store();
404        let acme = org(&s, "acme");
405        let set = |o: &OrgId, k, subj: &str, r| s.set_agent_identity(o, k, subj, r, "", "me");
406        let a = set(&acme, AgentKind::Tailnet, "tag:agents", Role::Member).unwrap();
407        assert_eq!(a.role, Role::Member);
408        let again = set(&acme, AgentKind::Tailnet, "TAG:agents", Role::Viewer).unwrap();
409        assert_eq!(again.id, a.id);
410        assert_eq!(again.role, Role::Viewer);
411        assert_eq!(s.list_agent_identities(&acme).unwrap().len(), 1);
412        assert!(set(&acme, AgentKind::Tailnet, "tag:x", Role::Owner).is_err());
413        assert!(s.remove_agent_identity(&acme, a.id).unwrap());
414        assert!(!s.remove_agent_identity(&acme, a.id).unwrap());
415        // An org's mapping cannot be removed through another org.
416        let b = set(&acme, AgentKind::Access, "svc.access", Role::Admin).unwrap();
417        let other = org(&s, "other");
418        assert!(!s.remove_agent_identity(&other, b.id).unwrap());
419        // An isb user's email is not an Access mapping.
420        s.create_user("alice@example.com", "Alice", None, false)
421            .unwrap();
422        assert!(matches!(
423            set(&acme, AgentKind::Access, "Alice@example.com", Role::Member),
424            Err(AuthError::Conflict(_))
425        ));
426        // Unknown org.
427        let ghost = OrgId::new("ghost").unwrap();
428        assert!(set(&ghost, AgentKind::Access, "x.access", Role::Member).is_err());
429    }
430
431    #[test]
432    fn tailnet_tags_and_logins_never_mix() {
433        let s = store();
434        let acme = org(&s, "acme");
435        let set = |subj: &str, r| s.set_agent_identity(&acme, AgentKind::Tailnet, subj, r, "", "x");
436        set("me@example.com", Role::Admin).unwrap();
437        set("tag:agents", Role::Viewer).unwrap();
438        // An untagged node: by login.
439        let p = s
440            .principal_for_tailnet("me@example.com", "laptop.t.ts.net", &[])
441            .unwrap()
442            .unwrap();
443        assert_eq!(p.user.email, "tailnet:me@example.com");
444        assert_eq!(p.orgs, vec![(acme.clone(), Role::Admin)]);
445        assert!(!p.platform_admin && p.is_agent() && p.user.id == 0);
446        // A tagged node whose owner's login is mapped: only its tags count.
447        let p = s
448            .principal_for_tailnet("me@example.com", "bot.t.ts.net", &["tag:agents".into()])
449            .unwrap()
450            .unwrap();
451        assert_eq!(p.user.email, "tailnet:bot.t.ts.net");
452        assert_eq!(p.orgs, vec![(acme.clone(), Role::Viewer)]);
453        // A tagged node with an unmapped tag is nobody, whatever its owner.
454        assert!(
455            s.principal_for_tailnet("me@example.com", "bot.t.ts.net", &["tag:other".into()])
456                .unwrap()
457                .is_none()
458        );
459        // An untagged node cannot use a tag mapping.
460        assert!(
461            s.principal_for_tailnet("agents@example.com", "x.t.ts.net", &[])
462                .unwrap()
463                .is_none()
464        );
465    }
466
467    #[test]
468    fn identities_pin_to_the_orgs_that_map_them() {
469        let s = store();
470        let acme = org(&s, "acme");
471        let beta = org(&s, "beta");
472        let set = |o: &OrgId, k, subj: &str, r| s.set_agent_identity(o, k, subj, r, "", "x");
473        set(&acme, AgentKind::Access, "svc.access", Role::Admin).unwrap();
474        set(&beta, AgentKind::Access, "svc.access", Role::Viewer).unwrap();
475        set(
476            &beta,
477            AgentKind::Tailnet,
478            "svc.access@example.com",
479            Role::Admin,
480        )
481        .unwrap();
482        let p = s
483            .principal_for_access_agent(None, Some("svc.access"))
484            .unwrap()
485            .unwrap();
486        assert_eq!(p.role_in(&acme), Some(Role::Admin));
487        assert_eq!(p.role_in(&beta), Some(Role::Viewer));
488        assert_eq!(p.role_in(&OrgId::new("gamma").unwrap()), None);
489        assert!(!p.can_admin_org(&beta) && p.can_read_org(&beta));
490        // A tailnet mapping is not an Access mapping.
491        assert!(
492            s.principal_for_access_agent(Some("svc.access@example.com"), None)
493                .unwrap()
494                .is_none()
495        );
496        // A person who is an isb user is not an agent identity, even if a
497        // row was made before the account existed.
498        set(&acme, AgentKind::Access, "eve@example.com", Role::Member).unwrap();
499        assert!(
500            s.principal_for_access_agent(Some("EVE@example.com"), None)
501                .unwrap()
502                .is_some()
503        );
504        s.create_user("eve@example.com", "Eve", None, false)
505            .unwrap();
506        assert!(
507            s.principal_for_access_agent(Some("eve@example.com"), None)
508                .unwrap()
509                .is_none()
510        );
511    }
512}