use rusqlite::{OptionalExtension, params};
use serde::Serialize;
use super::super::agent_identities::{AgentKind, normalize_subject};
use super::super::{AuthError, AuthResult, AuthStore};
pub const MAX_SUPERADMIN_IDENTITIES: i64 = 200;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SuperadminIdentity {
pub id: i64,
pub kind: AgentKind,
pub value: String,
pub added_at: i64,
pub added_by: String,
}
impl SuperadminIdentity {
pub fn admits_tailnet(&self, login: &str, tags: &[String]) -> bool {
if self.kind != AgentKind::Tailnet {
return false;
}
if tags.is_empty() {
!self.value.starts_with("tag:") && self.value.eq_ignore_ascii_case(login)
} else {
tags.iter().any(|t| t.eq_ignore_ascii_case(&self.value))
}
}
pub fn admits_access(&self, email: Option<&str>, client_id: Option<&str>) -> bool {
if self.kind != AgentKind::Access {
return false;
}
match (email, client_id) {
(Some(e), _) => self.value.contains('@') && self.value.eq_ignore_ascii_case(e),
(None, Some(cn)) => !self.value.contains('@') && self.value == cn,
(None, None) => false,
}
}
}
fn row(r: &rusqlite::Row) -> rusqlite::Result<SuperadminIdentity> {
let kind: String = r.get(1)?;
Ok(SuperadminIdentity {
id: r.get(0)?,
kind: AgentKind::parse(&kind).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
})?,
value: r.get(2)?,
added_at: r.get(3)?,
added_by: r.get(4)?,
})
}
const COLS: &str = "id, kind, value, added_at, added_by";
impl AuthStore {
pub fn add_superadmin_identity(
&self,
kind: AgentKind,
value: &str,
added_by: &str,
) -> AuthResult<SuperadminIdentity> {
let value = normalize_subject(kind, value)?;
let db = self.db();
let n: i64 = db.query_row("SELECT COUNT(*) FROM superadmin_identities", [], |r| {
r.get(0)
})?;
if n >= MAX_SUPERADMIN_IDENTITIES {
return Err(AuthError::Invalid(format!(
"{n} superadmin identities, the most isb keeps: remove one first"
)));
}
let now = self.now();
let r = db.execute(
"INSERT INTO superadmin_identities (kind, value, added_at, added_by)
VALUES (?1, ?2, ?3, ?4)",
params![kind.as_str(), value, now, added_by],
);
match r {
Ok(_) => {}
Err(rusqlite::Error::SqliteFailure(e, _))
if e.code == rusqlite::ErrorCode::ConstraintViolation =>
{
return Err(AuthError::Conflict(format!(
"{} {value} is a superadmin already",
kind.as_str()
)));
}
Err(e) => return Err(e.into()),
}
Ok(SuperadminIdentity {
id: db.last_insert_rowid(),
kind,
value,
added_at: now,
added_by: added_by.to_string(),
})
}
pub fn remove_superadmin_identity(
&self,
kind: AgentKind,
value: &str,
) -> AuthResult<SuperadminIdentity> {
let value = normalize_subject(kind, value)?;
let db = self.db();
let found = db
.query_row(
&format!("SELECT {COLS} FROM superadmin_identities WHERE kind = ?1 AND value = ?2"),
params![kind.as_str(), value],
row,
)
.optional()?;
let Some(found) = found else {
return Err(AuthError::NotFound(format!(
"{} {value} is not a superadmin in isb.db (one from --superadmin-{} is removed from that flag)",
kind.as_str(),
kind.as_str()
)));
};
db.execute(
"DELETE FROM superadmin_identities WHERE id = ?1",
[found.id],
)?;
Ok(found)
}
pub fn list_superadmin_identities(&self) -> AuthResult<Vec<SuperadminIdentity>> {
let db = self.db();
let mut st = db.prepare(&format!(
"SELECT {COLS} FROM superadmin_identities ORDER BY kind, value"
))?;
let rows = st.query_map([], row)?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthConfig;
#[test]
fn add_list_remove_and_validate() {
let s = AuthStore::in_memory(AuthConfig::default()).unwrap();
let a = s
.add_superadmin_identity(AgentKind::Access, " Alice@Example.com ", "local(uid 1)")
.unwrap();
assert_eq!(
(a.value.as_str(), a.added_by.as_str()),
("alice@example.com", "local(uid 1)")
);
s.add_superadmin_identity(AgentKind::Access, "Svc.Access", "t")
.unwrap();
s.add_superadmin_identity(AgentKind::Tailnet, "TAG:Agents", "t")
.unwrap();
s.add_superadmin_identity(AgentKind::Tailnet, "me@example.com", "t")
.unwrap();
assert!(matches!(
s.add_superadmin_identity(AgentKind::Access, "alice@EXAMPLE.com", "t"),
Err(AuthError::Conflict(_))
));
for (k, v) in [
(AgentKind::Access, ""),
(AgentKind::Access, " "),
(AgentKind::Access, "*@example.com"),
(AgentKind::Access, "@example.com"),
(AgentKind::Access, "a b@example.com"),
(AgentKind::Tailnet, "nobody"),
(AgentKind::Tailnet, "tag:"),
(AgentKind::Tailnet, "tag:a.b"),
(AgentKind::Tailnet, "a@x.io,b@x.io"),
] {
assert!(
matches!(
s.add_superadmin_identity(k, v, "t"),
Err(AuthError::Invalid(_))
),
"{k:?} {v:?}"
);
}
let l = s.list_superadmin_identities().unwrap();
let got: Vec<_> = l.iter().map(|i| (i.kind, i.value.as_str())).collect();
assert_eq!(
got,
vec![
(AgentKind::Access, "Svc.Access"),
(AgentKind::Access, "alice@example.com"),
(AgentKind::Tailnet, "me@example.com"),
(AgentKind::Tailnet, "tag:agents"),
]
);
let find = |v: &str| l.iter().find(|i| i.value == v).unwrap();
assert!(find("alice@example.com").admits_access(Some("ALICE@example.com"), None));
assert!(!find("alice@example.com").admits_access(None, Some("alice@example.com")));
assert!(find("Svc.Access").admits_access(None, Some("Svc.Access")));
assert!(!find("Svc.Access").admits_access(None, Some("svc.access")));
assert!(!find("Svc.Access").admits_tailnet("Svc.Access", &[]));
assert!(find("tag:agents").admits_tailnet("tagged-devices", &["tag:agents".into()]));
assert!(!find("tag:agents").admits_tailnet("tag:agents", &[]));
assert!(find("me@example.com").admits_tailnet("Me@example.com", &[]));
assert!(!find("me@example.com").admits_tailnet("me@example.com", &["tag:x".into()]));
assert!(!find("me@example.com").admits_access(Some("me@example.com"), None));
let r = s
.remove_superadmin_identity(AgentKind::Access, "ALICE@example.com")
.unwrap();
assert_eq!(r.id, a.id);
assert!(matches!(
s.remove_superadmin_identity(AgentKind::Access, "alice@example.com"),
Err(AuthError::NotFound(_))
));
assert_eq!(s.list_superadmin_identities().unwrap().len(), 3);
}
}