use std::collections::BTreeSet;
use async_trait::async_trait;
use regex::Regex;
use tracing::info;
use super::policy::{Check, StageSet, Verdict};
use super::{IdentifierContext, ListVerdict, check_lists, compile_matchers};
#[derive(Debug, Clone)]
pub struct EabIdentity {
pub kid: String,
pub label: Option<String>,
pub active: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Settings {
pub allow: Vec<String>,
pub deny: Vec<String>,
pub allow_regex: Vec<String>,
pub deny_regex: Vec<String>,
pub kids: Vec<String>,
pub require_active: bool,
}
#[derive(Debug)]
pub struct EabList {
allow: Vec<Regex>,
deny: Vec<Regex>,
kids: BTreeSet<String>,
require_active: bool,
}
impl EabList {
pub fn from_settings(name: &str, settings: &Settings) -> anyhow::Result<Self> {
if settings.allow.is_empty()
&& settings.deny.is_empty()
&& settings.allow_regex.is_empty()
&& settings.deny_regex.is_empty()
&& settings.kids.is_empty()
&& !settings.require_active
{
anyhow::bail!(
"filter.check.{name} names no labels, no kids and does not set \
require_active, so it only asks whether the account used EAB at all; \
list the credentials it is about, or drop the check"
);
}
let check = Self {
allow: compile_matchers(&settings.allow, &settings.allow_regex, name, "allow")?,
deny: compile_matchers(&settings.deny, &settings.deny_regex, name, "deny")?,
kids: settings.kids.iter().cloned().collect(),
require_active: settings.require_active,
};
info!(
event = "filter_eab_loaded",
outcome = "success",
check = name,
allow = check.allow.len(),
deny = check.deny.len(),
kids = check.kids.len(),
require_active = settings.require_active,
);
Ok(check)
}
fn decide(&self, context: &IdentifierContext<'_>) -> Verdict {
let stage = context.stage.as_str();
let Some(eab) = context.eab.as_ref() else {
return Verdict::Fail(format!(
"{stage} comes from an account that was not registered under an external \
account binding"
));
};
if self.require_active && !eab.active {
return Verdict::Fail(format!(
"the external account binding {} this account registered under has been \
revoked",
eab.kid
));
}
let label = eab.label.as_deref().unwrap_or_default();
if check_lists(&[], &self.deny, |pattern: &Regex| pattern.is_match(label))
== ListVerdict::Denied
{
return Verdict::Fail(format!(
"{stage} comes from external account binding {}, which policy refuses",
describe(eab)
));
}
let constrained = !self.allow.is_empty() || !self.kids.is_empty();
if constrained {
let permitted = self.kids.contains(&eab.kid)
|| (eab.label.is_some() && self.allow.iter().any(|p| p.is_match(label)));
if !permitted {
return Verdict::Fail(format!(
"{stage} comes from external account binding {}, which is not one this \
policy permits",
describe(eab)
));
}
}
Verdict::Pass
}
}
fn describe(eab: &EabIdentity) -> String {
eab.label
.as_deref()
.map_or_else(|| eab.kid.clone(), |label| format!("`{label}`"))
}
#[async_trait]
impl Check for EabList {
fn kind(&self) -> &'static str {
"eab"
}
fn stages(&self) -> StageSet {
StageSet::identifiers_only()
}
async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
self.decide(context)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter::IdentifierStage;
use crate::sqlite::order::Identifier;
use crate::testutil::dns_identifiers;
fn identity(label: Option<&str>, active: bool) -> EabIdentity {
EabIdentity {
kid: "11111111-2222-3333-4444-555555555555".to_string(),
label: label.map(std::string::ToString::to_string),
active,
}
}
fn built(settings: Settings) -> EabList {
EabList::from_settings("tenant", &settings).unwrap()
}
fn labels(allow: &[&str], deny: &[&str]) -> Settings {
Settings {
allow: allow.iter().map(std::string::ToString::to_string).collect(),
deny: deny.iter().map(std::string::ToString::to_string).collect(),
..Settings::default()
}
}
async fn verdict_for(check: &EabList, eab: Option<EabIdentity>) -> Verdict {
let identifiers: Vec<Identifier> = dns_identifiers(&["host.example.com"]);
check
.check_identifiers(&IdentifierContext {
client_ip: None,
account_id: "acct-1",
stage: IdentifierStage::NewOrder,
identifiers: &identifiers,
eab,
})
.await
}
fn assert_failed(verdict: Verdict, needle: &str) {
match verdict {
Verdict::Fail(detail) => {
assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[tokio::test]
async fn a_permitted_label_passes() {
let check = built(labels(&["tenant-a"], &[]));
assert_eq!(
verdict_for(&check, Some(identity(Some("tenant-a"), true))).await,
Verdict::Pass
);
}
#[tokio::test]
async fn another_tenants_label_is_refused_naming_it() {
let check = built(labels(&["tenant-a"], &[]));
assert_failed(
verdict_for(&check, Some(identity(Some("tenant-b"), true))).await,
"`tenant-b`",
);
}
#[tokio::test]
async fn labels_can_be_globbed() {
let check = built(labels(&["tenant-*"], &[]));
assert_eq!(
verdict_for(&check, Some(identity(Some("tenant-a"), true))).await,
Verdict::Pass
);
assert_failed(
verdict_for(&check, Some(identity(Some("other"), true))).await,
"not one this policy permits",
);
}
#[tokio::test]
async fn deny_wins_over_allow() {
let check = built(labels(&["tenant-*"], &["tenant-retired"]));
assert_failed(
verdict_for(&check, Some(identity(Some("tenant-retired"), true))).await,
"which policy refuses",
);
}
#[tokio::test]
async fn an_exact_kid_is_permitted_beside_the_labels() {
let settings = Settings {
allow: vec!["tenant-a".to_string()],
kids: vec!["11111111-2222-3333-4444-555555555555".to_string()],
..Settings::default()
};
let check = built(settings);
assert_eq!(
verdict_for(&check, Some(identity(Some("something-else"), true))).await,
Verdict::Pass
);
let other = EabIdentity {
kid: "99999999-9999-9999-9999-999999999999".to_string(),
label: Some("tenant-a".to_string()),
active: true,
};
assert_eq!(verdict_for(&check, Some(other)).await, Verdict::Pass);
}
#[tokio::test]
async fn a_kids_only_check_refuses_an_unlisted_credential() {
let settings = Settings {
kids: vec!["00000000-0000-0000-0000-000000000000".to_string()],
..Settings::default()
};
assert_failed(
verdict_for(&built(settings), Some(identity(Some("tenant-a"), true))).await,
"not one this policy permits",
);
}
#[tokio::test]
async fn an_account_with_no_credential_is_refused() {
let check = built(labels(&["tenant-a"], &[]));
assert_failed(
verdict_for(&check, None).await,
"not registered under an external account binding",
);
}
#[tokio::test]
async fn an_unlabelled_credential_cannot_match_a_label_allowlist() {
let check = built(labels(&["tenant-a"], &[]));
let verdict = verdict_for(&check, Some(identity(None, true))).await;
assert_failed(verdict, "not one this policy permits");
}
#[tokio::test]
async fn a_revoked_credential_passes_unless_require_active_is_set() {
let permissive = built(labels(&["tenant-a"], &[]));
assert_eq!(
verdict_for(&permissive, Some(identity(Some("tenant-a"), false))).await,
Verdict::Pass,
"revocation stops new registrations, not existing accounts, by default"
);
let strict = built(Settings {
allow: vec!["tenant-a".to_string()],
require_active: true,
..Settings::default()
});
assert_failed(
verdict_for(&strict, Some(identity(Some("tenant-a"), false))).await,
"has been revoked",
);
}
#[tokio::test]
async fn require_active_alone_is_a_usable_check() {
let check = built(Settings {
require_active: true,
..Settings::default()
});
assert_eq!(
verdict_for(&check, Some(identity(Some("anyone"), true))).await,
Verdict::Pass
);
assert_failed(
verdict_for(&check, Some(identity(None, false))).await,
"has been revoked",
);
}
#[test]
fn a_check_that_asks_nothing_is_a_startup_error() {
let error = EabList::from_settings("tenant", &Settings::default())
.unwrap_err()
.to_string();
assert!(error.contains("names no labels"), "{error}");
assert!(error.contains("filter.check.tenant"), "{error}");
}
#[test]
fn reports_its_type_and_stages() {
let check = built(labels(&["t"], &[]));
assert_eq!(check.kind(), "eab");
assert_eq!(check.stages(), StageSet::identifiers_only());
}
}