Skip to main content

acme_proxy/filter/
eab.rs

1//! The `eab` check: which credential the account was registered under.
2//!
3//! The multi-tenant lever. An External Account Binding credential is minted out
4//! of band by `acme-proxy eab create --label <text>`, **before any account
5//! exists**, so its label is a handle an operator can put in configuration up
6//! front — unlike an account id, which is a generated UUID you could only
7//! discover after the fact. Credentials are deliberately reusable, so one label
8//! naturally names a tenant:
9//!
10//! ```toml
11//! [filter.check.tenant-a]
12//! type  = "eab"
13//! allow = ["tenant-a"]
14//!
15//! [filter.check.tenant-a-names]
16//! type  = "identifiers"
17//! allow = ["*.tenant-a.example.com"]
18//!
19//! [filter.rule.tenant-a]
20//! when = "tenant-a and tenant-a-names"
21//! then = "allow"
22//! ```
23//!
24//! It costs no schema change: `accounts.eab_kid` has recorded this since EAB
25//! was implemented. That column's own migration calls it "an audit trail only";
26//! this promotes it to a policy input, which is a change in what the column
27//! *means* rather than in what it holds.
28//!
29//! ## Fail-closed on an account with no credential
30//!
31//! An account registered without EAB has no kid, and this check refuses it.
32//! That is the only defensible reading: the question is "which credential
33//! authorised this account", and "none" is not an answer that can satisfy a
34//! tenant rule. It also means an `eab` check while `eab.enabled` is off could
35//! never do anything but refuse, which is a startup error rather than a policy.
36//!
37//! ## `require_active`
38//!
39//! Revoking an EAB credential stops new *registrations*; accounts already
40//! created under it keep issuing for ever, which is what the column's
41//! audit-trail-only framing implied. `require_active = true` changes that for
42//! this check, so revocation reaches existing accounts too. Off by default,
43//! because turning it on retroactively changes what `eab revoke` means — it is
44//! the lever an operator reaches for when a tenant's credential leaks.
45
46use 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/// What the handler resolved about the credential an account registered under.
56///
57/// Present on [`IdentifierContext`] only when the policy contains an `eab`
58/// check — see [`FilterPolicy::needs_eab`](super::FilterPolicy::needs_eab) —
59/// so a policy without one pays for no lookup.
60#[derive(Debug, Clone)]
61pub struct EabIdentity {
62    pub kid: String,
63    /// The operator's label, if the credential was minted with one.
64    pub label: Option<String>,
65    /// Whether the credential is still `active` rather than revoked.
66    pub active: bool,
67}
68
69/// Resolved `[filter.check.<name>]` settings for `type = "eab"`.
70#[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/// Accepts or refuses by the EAB credential behind the requesting account.
81#[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    /// Compiles the label patterns, failing startup on an inert check.
91    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        // `deny` reads the label, which is the only operator-authored handle a
143        // credential carries. A credential with no label can never match one.
144        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        // `kids` is a second *allow* source rather than a separate gate: an
155        // operator either names the tenant by its label or pins the credential
156        // itself, and either should be enough.
157        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
174/// How a credential is named in a refusal: its label if it has one, else its
175/// kid, which is at least greppable in `acme-proxy eab list`.
176fn 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    /// Identifiers only: at the connection stage no account has been
189    /// authenticated, so there is no credential to ask about.
190    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    /// `kids` pins the credential itself, for an operator who would rather not
289    /// depend on labels being unique. It is a second allow source, so either
290    /// half satisfying the check is enough.
291    #[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        // Matched by kid, despite a label the allow list does not name.
301        assert_eq!(
302            verdict_for(&check, Some(identity(Some("something-else"), true))).await,
303            Verdict::Pass
304        );
305        // And a different credential with the permitted label still passes.
306        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    /// The fail-closed reading: "no credential" cannot satisfy a rule that is
327    /// about which credential was used.
328    #[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    /// A credential with no label cannot match a label allowlist, which is the
338    /// same rule stated from the other side.
339    #[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    /// `require_active` alone is a meaningful policy: "any tenant, but not one
367    /// whose credential we have withdrawn".
368    #[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}