1use std::collections::BTreeSet;
47
48use async_trait::async_trait;
49use regex::Regex;
50use tracing::info;
51
52use super::policy::{Check, StageSet, Verdict};
53use super::{IdentifierContext, ListVerdict, check_lists, compile_matchers};
54
55#[derive(Debug, Clone)]
61pub struct EabIdentity {
62 pub kid: String,
63 pub label: Option<String>,
65 pub active: bool,
67}
68
69#[derive(Debug, Clone, Default)]
71pub struct Settings {
72 pub allow: Vec<String>,
73 pub deny: Vec<String>,
74 pub allow_regex: Vec<String>,
75 pub deny_regex: Vec<String>,
76 pub kids: Vec<String>,
77 pub require_active: bool,
78}
79
80#[derive(Debug)]
82pub struct EabList {
83 allow: Vec<Regex>,
84 deny: Vec<Regex>,
85 kids: BTreeSet<String>,
86 require_active: bool,
87}
88
89impl EabList {
90 pub fn from_settings(name: &str, settings: &Settings) -> anyhow::Result<Self> {
92 if settings.allow.is_empty()
93 && settings.deny.is_empty()
94 && settings.allow_regex.is_empty()
95 && settings.deny_regex.is_empty()
96 && settings.kids.is_empty()
97 && !settings.require_active
98 {
99 anyhow::bail!(
100 "filter.check.{name} names no labels, no kids and does not set \
101 require_active, so it only asks whether the account used EAB at all; \
102 list the credentials it is about, or drop the check"
103 );
104 }
105
106 let check = Self {
107 allow: compile_matchers(&settings.allow, &settings.allow_regex, name, "allow")?,
108 deny: compile_matchers(&settings.deny, &settings.deny_regex, name, "deny")?,
109 kids: settings.kids.iter().cloned().collect(),
110 require_active: settings.require_active,
111 };
112 info!(
113 event = "filter_eab_loaded",
114 outcome = "success",
115 check = name,
116 allow = check.allow.len(),
117 deny = check.deny.len(),
118 kids = check.kids.len(),
119 require_active = settings.require_active,
120 );
121 Ok(check)
122 }
123
124 fn decide(&self, context: &IdentifierContext<'_>) -> Verdict {
125 let stage = context.stage.as_str();
126
127 let Some(eab) = context.eab.as_ref() else {
128 return Verdict::Fail(format!(
129 "{stage} comes from an account that was not registered under an external \
130 account binding"
131 ));
132 };
133
134 if self.require_active && !eab.active {
135 return Verdict::Fail(format!(
136 "the external account binding {} this account registered under has been \
137 revoked",
138 eab.kid
139 ));
140 }
141
142 let label = eab.label.as_deref().unwrap_or_default();
145 if check_lists(&[], &self.deny, |pattern: &Regex| pattern.is_match(label))
146 == ListVerdict::Denied
147 {
148 return Verdict::Fail(format!(
149 "{stage} comes from external account binding {}, which policy refuses",
150 describe(eab)
151 ));
152 }
153
154 let constrained = !self.allow.is_empty() || !self.kids.is_empty();
158 if constrained {
159 let permitted = self.kids.contains(&eab.kid.to_string())
160 || (eab.label.is_some() && self.allow.iter().any(|p| p.is_match(label)));
161 if !permitted {
162 return Verdict::Fail(format!(
163 "{stage} comes from external account binding {}, which is not one this \
164 policy permits",
165 describe(eab)
166 ));
167 }
168 }
169
170 Verdict::Pass
171 }
172}
173
174fn describe(eab: &EabIdentity) -> String {
177 eab.label
178 .as_deref()
179 .map_or_else(|| eab.kid.clone(), |label| format!("`{label}`"))
180}
181
182#[async_trait]
183impl Check for EabList {
184 fn kind(&self) -> &'static str {
185 "eab"
186 }
187
188 fn stages(&self) -> StageSet {
191 StageSet::identifiers_only()
192 }
193
194 async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
195 self.decide(context)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::filter::IdentifierStage;
203 use crate::sqlite::order::Identifier;
204 use crate::testutil::dns_identifiers;
205
206 fn identity(label: Option<&str>, active: bool) -> EabIdentity {
207 EabIdentity {
208 kid: "11111111-2222-3333-4444-555555555555".to_string(),
209 label: label.map(std::string::ToString::to_string),
210 active,
211 }
212 }
213
214 fn built(settings: Settings) -> EabList {
215 EabList::from_settings("tenant", &settings).unwrap()
216 }
217
218 fn labels(allow: &[&str], deny: &[&str]) -> Settings {
219 Settings {
220 allow: allow.iter().map(std::string::ToString::to_string).collect(),
221 deny: deny.iter().map(std::string::ToString::to_string).collect(),
222 ..Settings::default()
223 }
224 }
225
226 async fn verdict_for(check: &EabList, eab: Option<EabIdentity>) -> Verdict {
227 let identifiers: Vec<Identifier> = dns_identifiers(&["host.example.com"]);
228 check
229 .check_identifiers(&IdentifierContext {
230 client_ip: None,
231 account_id: "acct-1",
232 stage: IdentifierStage::NewOrder,
233 identifiers: &identifiers,
234 eab,
235 })
236 .await
237 }
238
239 fn assert_failed(verdict: Verdict, needle: &str) {
240 match verdict {
241 Verdict::Fail(detail) => {
242 assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
243 }
244 other => panic!("expected Fail, got {other:?}"),
245 }
246 }
247
248 #[tokio::test]
249 async fn a_permitted_label_passes() {
250 let check = built(labels(&["tenant-a"], &[]));
251 assert_eq!(
252 verdict_for(&check, Some(identity(Some("tenant-a"), true))).await,
253 Verdict::Pass
254 );
255 }
256
257 #[tokio::test]
258 async fn another_tenants_label_is_refused_naming_it() {
259 let check = built(labels(&["tenant-a"], &[]));
260 assert_failed(
261 verdict_for(&check, Some(identity(Some("tenant-b"), true))).await,
262 "`tenant-b`",
263 );
264 }
265
266 #[tokio::test]
267 async fn labels_can_be_globbed() {
268 let check = built(labels(&["tenant-*"], &[]));
269 assert_eq!(
270 verdict_for(&check, Some(identity(Some("tenant-a"), true))).await,
271 Verdict::Pass
272 );
273 assert_failed(
274 verdict_for(&check, Some(identity(Some("other"), true))).await,
275 "not one this policy permits",
276 );
277 }
278
279 #[tokio::test]
280 async fn deny_wins_over_allow() {
281 let check = built(labels(&["tenant-*"], &["tenant-retired"]));
282 assert_failed(
283 verdict_for(&check, Some(identity(Some("tenant-retired"), true))).await,
284 "which policy refuses",
285 );
286 }
287
288 #[tokio::test]
292 async fn an_exact_kid_is_permitted_beside_the_labels() {
293 let settings = Settings {
294 allow: vec!["tenant-a".to_string()],
295 kids: vec!["11111111-2222-3333-4444-555555555555".to_string()],
296 ..Settings::default()
297 };
298 let check = built(settings);
299
300 assert_eq!(
302 verdict_for(&check, Some(identity(Some("something-else"), true))).await,
303 Verdict::Pass
304 );
305 let other = EabIdentity {
307 kid: "99999999-9999-9999-9999-999999999999".to_string(),
308 label: Some("tenant-a".to_string()),
309 active: true,
310 };
311 assert_eq!(verdict_for(&check, Some(other)).await, Verdict::Pass);
312 }
313
314 #[tokio::test]
315 async fn a_kids_only_check_refuses_an_unlisted_credential() {
316 let settings = Settings {
317 kids: vec!["00000000-0000-0000-0000-000000000000".to_string()],
318 ..Settings::default()
319 };
320 assert_failed(
321 verdict_for(&built(settings), Some(identity(Some("tenant-a"), true))).await,
322 "not one this policy permits",
323 );
324 }
325
326 #[tokio::test]
329 async fn an_account_with_no_credential_is_refused() {
330 let check = built(labels(&["tenant-a"], &[]));
331 assert_failed(
332 verdict_for(&check, None).await,
333 "not registered under an external account binding",
334 );
335 }
336
337 #[tokio::test]
340 async fn an_unlabelled_credential_cannot_match_a_label_allowlist() {
341 let check = built(labels(&["tenant-a"], &[]));
342 let verdict = verdict_for(&check, Some(identity(None, true))).await;
343 assert_failed(verdict, "not one this policy permits");
344 }
345
346 #[tokio::test]
347 async fn a_revoked_credential_passes_unless_require_active_is_set() {
348 let permissive = built(labels(&["tenant-a"], &[]));
349 assert_eq!(
350 verdict_for(&permissive, Some(identity(Some("tenant-a"), false))).await,
351 Verdict::Pass,
352 "revocation stops new registrations, not existing accounts, by default"
353 );
354
355 let strict = built(Settings {
356 allow: vec!["tenant-a".to_string()],
357 require_active: true,
358 ..Settings::default()
359 });
360 assert_failed(
361 verdict_for(&strict, Some(identity(Some("tenant-a"), false))).await,
362 "has been revoked",
363 );
364 }
365
366 #[tokio::test]
369 async fn require_active_alone_is_a_usable_check() {
370 let check = built(Settings {
371 require_active: true,
372 ..Settings::default()
373 });
374 assert_eq!(
375 verdict_for(&check, Some(identity(Some("anyone"), true))).await,
376 Verdict::Pass
377 );
378 assert_failed(
379 verdict_for(&check, Some(identity(None, false))).await,
380 "has been revoked",
381 );
382 }
383
384 #[test]
385 fn a_check_that_asks_nothing_is_a_startup_error() {
386 let error = EabList::from_settings("tenant", &Settings::default())
387 .unwrap_err()
388 .to_string();
389 assert!(error.contains("names no labels"), "{error}");
390 assert!(error.contains("filter.check.tenant"), "{error}");
391 }
392
393 #[test]
394 fn reports_its_type_and_stages() {
395 let check = built(labels(&["t"], &[]));
396 assert_eq!(check.kind(), "eab");
397 assert_eq!(check.stages(), StageSet::identifiers_only());
398 }
399}