Skip to main content

micromegas_auth/
db_api_key.rs

1//! DB-backed API key authentication.
2//!
3//! Holds API keys in two Postgres tables — `ingestion_api_keys` and
4//! `analytics_api_keys` (migration v5, `rust/ingestion/src/sql_migration.rs`) —
5//! each row storing only a SHA-256 hash of the key, plus a
6//! `created_at`/`created_by`/`last_used_at`/`revoked_at`/`revoked_by` audit trail.
7//! [`crate::db_api_key::DbApiKeyAuthProvider`] validates by hash-indexed lookup behind a short-TTL
8//! `moka` cache.
9
10use crate::env::resolve_prefixed_var;
11use crate::ip_allowlist::IpAllowlist;
12use crate::types::{AuthContext, AuthProvider, AuthType, ProviderUnavailable, RequestParts};
13use anyhow::{Context, Result, anyhow};
14use base64::Engine;
15use chrono::Utc;
16use moka::future::Cache;
17use rand::Rng;
18use sha2::{Digest, Sha256};
19use sqlx::PgPool;
20use sqlx::Row;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicI64, Ordering};
23use std::time::Duration;
24
25/// Which key table a provider (or the management API) is bound to.
26///
27/// The table name is always a `&'static str` literal — never derived from caller
28/// input — so the SQL built from it (below) can never be injected into.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ApiKeyTable {
31    /// `ingestion_api_keys` — write credentials, minted/listed/revoked over HTTP.
32    Ingestion,
33    /// `analytics_api_keys` — read credentials, minted/listed/revoked over
34    /// HTTP through `analytics-web-srv`'s own routes (`/api/analytics-api-keys*`,
35    /// see the admin runbook) — not through this crate's ingestion-hosted API.
36    Analytics,
37}
38
39impl ApiKeyTable {
40    /// Static table name.
41    pub fn table_name(self) -> &'static str {
42        match self {
43            ApiKeyTable::Ingestion => "ingestion_api_keys",
44            ApiKeyTable::Analytics => "analytics_api_keys",
45        }
46    }
47
48    /// Whether this table carries a write-side `audience` column (`ingestion_api_keys` only,
49    /// migration v6). `analytics_api_keys`'s read-side mirror is `read_audiences`, a set-valued
50    /// grant on the caller rather than a column on the key row.
51    pub fn has_audience(self) -> bool {
52        matches!(self, ApiKeyTable::Ingestion)
53    }
54
55    /// Whether a key from this table may delegate (mint/act as another principal) --
56    /// `analytics_api_keys` only.
57    pub fn allows_delegation(self) -> bool {
58        matches!(self, ApiKeyTable::Analytics)
59    }
60}
61
62/// Cache and audit knobs for [`DbApiKeyAuthProvider`], read from env with defaults.
63#[derive(Clone, Copy, Debug)]
64pub struct DbApiKeyConfig {
65    /// `MICROMEGAS_API_KEY_CACHE_SIZE`, default 10_000.
66    pub cache_size: u64,
67    /// `MICROMEGAS_AUTH_CACHE_TTL_SECONDS`, default 60 -- a single flat,
68    /// unprefixed knob (no `{prefix}_` role variant) shared with
69    /// `DbAudienceGrantsConfig`/`DbGroupsConfig`'s own positive-cache TTL: one
70    /// value governs the API-key, audience-grant, and group snapshot caches
71    /// process-wide, across every role. Also the bound on revocation latency: a
72    /// `DELETE` writes `revoked_at` but cannot invalidate a remote process's
73    /// cache, so a revoked key keeps authenticating until this TTL elapses.
74    pub cache_ttl_secs: u64,
75    /// `MICROMEGAS_API_KEY_UNKNOWN_CACHE_TTL_SECONDS`, default 10. Shorter than
76    /// `cache_ttl_secs` so a freshly minted key is not masked by an earlier probe
77    /// of that same (not-yet-existing) string for longer than necessary.
78    pub unknown_cache_ttl_secs: u64,
79    /// `MICROMEGAS_API_KEY_UNKNOWN_CACHE_SIZE`, default 10_000.
80    pub unknown_cache_size: u64,
81}
82
83pub(crate) fn resolve_u64(prefix: &str, suffix: &str, default: u64) -> u64 {
84    let var = resolve_prefixed_var(prefix, suffix);
85    let raw = std::env::var(&var).ok();
86    match raw {
87        Some(s) => s.parse::<u64>().unwrap_or_else(|_| {
88            micromegas_tracing::warn!("Invalid {var} value '{s}', using default {default}");
89            default
90        }),
91        None => default,
92    }
93}
94
95impl DbApiKeyConfig {
96    /// Resolves each `API_KEY_`-specific knob as `{prefix}_*` first, falling back to the
97    /// unprefixed name, the same `{prefix}_*`-with-fallback convention
98    /// [`crate::env::resolve_prefixed_var`] implements for every other knob in this crate. With
99    /// an empty prefix this is identical to the unprefixed vars, so an unprefixed caller just
100    /// passes `""`.
101    ///
102    /// `cache_ttl_secs` is the exception: it reads the **flat, unprefixed**
103    /// `MICROMEGAS_AUTH_CACHE_TTL_SECONDS` knob directly (`prefix` is ignored for this one
104    /// field), the same single process-wide value `DbAudienceGrantsConfig`/`DbGroupsConfig`
105    /// read -- there is exactly one value for this knob, no per-role distinction.
106    pub fn from_env_with_prefix(prefix: &str) -> Self {
107        Self {
108            cache_size: resolve_u64(prefix, "API_KEY_CACHE_SIZE", 10_000),
109            cache_ttl_secs: resolve_u64("", "AUTH_CACHE_TTL_SECONDS", 60),
110            unknown_cache_ttl_secs: resolve_u64(prefix, "API_KEY_UNKNOWN_CACHE_TTL_SECONDS", 10),
111            unknown_cache_size: resolve_u64(prefix, "API_KEY_UNKNOWN_CACHE_SIZE", 10_000),
112        }
113    }
114}
115
116/// SHA-256 of the full key string. The only place a key is hashed; the mint
117/// routes (`rust/analytics-web-srv/src/ingestion_keys.rs`, `analytics_keys.rs`)
118/// and this provider's lookup go through the same digest definition.
119pub fn hash_key(key: &str) -> [u8; 32] {
120    let mut hasher = Sha256::new();
121    hasher.update(key.as_bytes());
122    hasher.finalize().into()
123}
124
125/// 256 bits of OS entropy, base64url-nopad, `mmk_`-prefixed. `mmk_` makes minted
126/// keys recognizable to secret scanners; it is cosmetic to validation, since
127/// [`hash_key`] covers the whole string rather than parsing the prefix.
128pub fn generate_key() -> String {
129    let mut rng = rand::rng();
130    let bytes: [u8; 32] = rng.random();
131    let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
132    format!("mmk_{encoded}")
133}
134
135/// Builds a small, dedicated connection pool for key-store lookups from an
136/// existing pool's options — rather than sharing the caller's lake pool
137/// directly. Inherits the lake pool's options wholesale (including any
138/// `after_connect`/`before_acquire` hooks, e.g. the read-only-connection checks in
139/// `micromegas_ingestion::data_lake_connection`), not just its connect options, so a strict or
140/// prefer-fallback lake pool's write-session guarantee carries over to the key store.
141/// `max_connections(4)`, `acquire_timeout(2s)`: a credential flood or a
142/// DB outage on the key-lookup path can therefore never starve the write path of
143/// connections, and a lookup fails fast into its 503 rather than blocking up to
144/// sqlx's default 30s `acquire_timeout`.
145pub fn dedicated_key_store_pool(lake_pool: &PgPool) -> PgPool {
146    let connect_options = (*lake_pool.connect_options()).clone();
147    lake_pool
148        .options()
149        .clone()
150        .max_connections(4)
151        .acquire_timeout(Duration::from_secs(2))
152        .connect_lazy_with(connect_options)
153}
154
155/// True when `err`'s SQLSTATE is `25006` (`cannot execute ... in a read-only transaction`) --
156/// the code the `last_used_at` update raises when its connection turned read-only mid-query.
157/// A local copy of `micromegas_ingestion::data_lake_connection::is_read_only_violation`'s check:
158/// `auth` doesn't depend on `ingestion`.
159pub fn is_read_only_error(err: &sqlx::Error) -> bool {
160    err.as_database_error()
161        .and_then(|db_err| db_err.code())
162        .is_some_and(|code| code == "25006")
163}
164
165/// `SELECT EXISTS(SELECT 1 FROM <table> WHERE revoked_at IS NULL)` — used by
166/// `ProviderBuilder::build()` (`rust/auth/src/default_provider.rs`) to decide
167/// whether a non-empty key store counts as "auth configured" on its own. A
168/// missing relation (e.g. a schema that has not reached migration v5 yet)
169/// surfaces as an `Err` here and must be propagated, never treated as "empty".
170pub async fn key_store_has_live_rows(pool: &PgPool, table: ApiKeyTable) -> Result<bool> {
171    let row = sqlx::query(&format!(
172        "SELECT EXISTS(SELECT 1 FROM {} WHERE revoked_at IS NULL) AS has_rows",
173        table.table_name()
174    ))
175    .fetch_one(pool)
176    .await
177    .with_context(|| format!("checking whether {} has any live key", table.table_name()))?;
178    let has_rows: bool = row
179        .try_get("has_rows")
180        .with_context(|| "reading has_rows")?;
181    Ok(has_rows)
182}
183
184/// hash -> (key_id, name, audience, allowlist) for a key known to be live.
185struct KeyRow {
186    key_id: uuid::Uuid,
187    name: String,
188    /// `Some` for `Ingestion` (the column is `NOT NULL` as of migration v6), `None` for
189    /// `Analytics` (which carries no `audience` column at all).
190    audience: Option<String>,
191    /// Parsed once per cache fill (not per request) from the `allowed_cidrs` column --
192    /// `IpAllowlist::parse` is fed `&[]` when the column read back `None` (every pre-v11 row),
193    /// giving the same "no restriction" allowlist as an empty stored array.
194    allowlist: IpAllowlist,
195}
196
197/// Distinguishes "the DB answered: no such live key" and "the row exists but is
198/// malformed" -- both credential rejections -- from "the DB could not be reached
199/// at all", which alone becomes a [`ProviderUnavailable`] and must never populate
200/// either cache.
201#[derive(thiserror::Error, Debug)]
202enum LookupError {
203    #[error("no such live key")]
204    NotFound,
205    #[error("{0}")]
206    Db(anyhow::Error),
207    /// A row was read successfully but its `allowed_cidrs` column could not be decoded or
208    /// parsed -- e.g. a hand-edited row (`mkdocs/docs/admin/api-keys.md` documents that as
209    /// supported) with a CIDR the column has no `CHECK` constraint to reject. Not a DB outage:
210    /// retrying the same query only reproduces the same malformed row.
211    #[error("{0}")]
212    Invalid(anyhow::Error),
213}
214
215/// Shared throttle primitive: true at most once per `window_secs`, checked-and-set
216/// via a single `AtomicI64` "last logged at" timestamp. Backs both `maybe_log_error`
217/// and the read-only-fallback `warn!` in `lookup_key_row` -- each keeps its own
218/// `AtomicI64` so a burst of one kind of event can never suppress the other's log.
219fn should_log(last_logged_at: &AtomicI64, window_secs: i64) -> bool {
220    let now = Utc::now().timestamp();
221    let prev = last_logged_at.load(Ordering::Relaxed);
222    now.saturating_sub(prev) >= window_secs
223        && last_logged_at
224            .compare_exchange(prev, now, Ordering::Relaxed, Ordering::Relaxed)
225            .is_ok()
226}
227
228/// Rate-limits the outage `error!` log to at most once per `window_secs` (per
229/// table) -- not once per rejected request. `DbApiKeyAuthProvider` sits last in the
230/// auth chain, so during an outage every non-env-key, non-JWT request reaches it; an
231/// unconditional `error!` would flood `log_entries` with the outage's own noise
232/// on the highest-volume service in the deployment.
233fn maybe_log_error(last_logged_at: &AtomicI64, window_secs: i64, table: &str, err: &anyhow::Error) {
234    if should_log(last_logged_at, window_secs) {
235        micromegas_tracing::error!("db_api_key store error (table={table}): {err:#}");
236    }
237}
238
239/// DB-backed API key authentication provider, bound to one [`ApiKeyTable`].
240///
241/// Every DB error is wrapped in [`ProviderUnavailable`] before being returned and
242/// emits `imetric!("db_api_key_error_count", "count", table = name, 1)` unconditionally
243/// — the metric is unconditional even on requests whose `error!` line was
244/// suppressed by `maybe_log_error`'s rate limit.
245pub struct DbApiKeyAuthProvider {
246    pool: PgPool,
247    table: ApiKeyTable,
248    /// hash -> (key_id, name) for keys known to be live.
249    valid: Cache<[u8; 32], Arc<KeyRow>>,
250    /// hash -> () for tokens the DB answered "no such live key" for, and for
251    /// tokens whose row exists but failed to decode/parse (`LookupError::Invalid`) --
252    /// both are credential rejections from the caller's point of view.
253    unknown: Cache<[u8; 32], ()>,
254    /// Rate-limit window for the outage `error!` log — mirrors `cache_ttl_secs`,
255    /// floored so a zero or very low cache TTL doesn't also disable log
256    /// rate-limiting.
257    error_log_window_secs: i64,
258    last_logged_at: Arc<AtomicI64>,
259    /// Separate throttle state for the read-only-fallback `warn!` in `lookup_key_row` --
260    /// its own `AtomicI64` so a flood of unknown tokens against a read-only connection can
261    /// never suppress `last_logged_at`'s genuine DB-error log, or vice versa.
262    last_read_only_warn_at: Arc<AtomicI64>,
263}
264
265impl DbApiKeyAuthProvider {
266    /// Creates a new provider bound to `table`, using `pool` for lookups (the
267    /// caller is expected to pass a [`dedicated_key_store_pool`], not the lake
268    /// pool itself).
269    pub fn new(pool: PgPool, table: ApiKeyTable, config: DbApiKeyConfig) -> Self {
270        let valid = Cache::builder()
271            .max_capacity(config.cache_size)
272            .time_to_live(Duration::from_secs(config.cache_ttl_secs))
273            .build();
274        let unknown = Cache::builder()
275            .max_capacity(config.unknown_cache_size)
276            .time_to_live(Duration::from_secs(config.unknown_cache_ttl_secs))
277            .build();
278        Self {
279            pool,
280            table,
281            valid,
282            unknown,
283            error_log_window_secs: config.cache_ttl_secs.max(60) as i64,
284            last_logged_at: Arc::new(AtomicI64::new(0)),
285            last_read_only_warn_at: Arc::new(AtomicI64::new(0)),
286        }
287    }
288}
289
290/// Looks up a live key's row, bumping `last_used_at` on the way when possible.
291///
292/// Tries the `UPDATE ... RETURNING` first. On a `25006` (the connection turned read-only, e.g.
293/// a `WritablePolicy::Prefer` FlightSQL pool that fell back to a replica), falls back to a plain
294/// `SELECT` of the same columns and skips the bump rather than failing the whole lookup --
295/// `last_used_at` is best-effort telemetry, not something a caller's auth should depend on.
296/// Unlike valid keys (cached in `self.valid`), an unknown token reaches this fallback each time
297/// it's a distinct token missing `self.unknown`, and again each time the same token is retried
298/// after `self.unknown`'s TTL (`unknown_cache_ttl_secs`) has expired -- so the `warn!` is
299/// throttled via `last_read_only_warn_at`/`window_secs` rather than logged unconditionally.
300async fn lookup_key_row(
301    pool: &PgPool,
302    table: ApiKeyTable,
303    hash: &[u8; 32],
304    returning: &str,
305    last_read_only_warn_at: &AtomicI64,
306    window_secs: i64,
307) -> Result<Option<sqlx::postgres::PgRow>, sqlx::Error> {
308    let update_result = sqlx::query(&format!(
309        "UPDATE {} SET last_used_at = now() WHERE key_hash = $1 AND revoked_at IS NULL RETURNING {returning}",
310        table.table_name()
311    ))
312    .bind(&hash[..])
313    .fetch_optional(pool)
314    .await;
315    match update_result {
316        Err(e) if is_read_only_error(&e) => {
317            if should_log(last_read_only_warn_at, window_secs) {
318                micromegas_tracing::warn!(
319                    "db_api_key lookup on a read-only connection (table={}): skipping last_used_at bump",
320                    table.table_name()
321                );
322            }
323            sqlx::query(&format!(
324                "SELECT {returning} FROM {} WHERE key_hash = $1 AND revoked_at IS NULL",
325                table.table_name()
326            ))
327            .bind(&hash[..])
328            .fetch_optional(pool)
329            .await
330        }
331        other => other,
332    }
333}
334
335#[async_trait::async_trait]
336impl AuthProvider for DbApiKeyAuthProvider {
337    async fn validate_request(&self, parts: &dyn RequestParts) -> Result<AuthContext> {
338        let token = parts
339            .bearer_token()
340            .ok_or_else(|| anyhow!("missing bearer token"))?;
341        let hash = hash_key(token);
342
343        // A repeated probe of the same bogus token is free after the first
344        // attempt; a flood of distinct bogus tokens still costs one DB round trip
345        // per request.
346        if self.unknown.get(&hash).await.is_some() {
347            anyhow::bail!("invalid API token");
348        }
349
350        let pool = self.pool.clone();
351        let table = self.table;
352        let last_logged_at = self.last_logged_at.clone();
353        let last_read_only_warn_at = self.last_read_only_warn_at.clone();
354        let window_secs = self.error_log_window_secs;
355
356        // `try_get_with`, not a plain `get`/`insert` pair: among concurrent
357        // callers for the same hash, exactly one runs this loader — the rest
358        // await its result. This is what rate-limits the `last_used_at` write to
359        // once per cache TTL per key, not once per concurrent request.
360        let result = self
361            .valid
362            .try_get_with(hash, async move {
363                // Built from `&'static str` literals alongside `table.table_name()`, so
364                // nothing caller-supplied ever reaches this SQL.
365                let returning = if table.has_audience() {
366                    "key_id, name, audience, allowed_cidrs"
367                } else {
368                    "key_id, name, allowed_cidrs"
369                };
370                let row = lookup_key_row(
371                    &pool,
372                    table,
373                    &hash,
374                    returning,
375                    &last_read_only_warn_at,
376                    window_secs,
377                )
378                .await
379                .map_err(|e| {
380                    let err = anyhow::Error::from(e)
381                        .context(format!("looking up key in {}", table.table_name()));
382                    // Unconditional: fires on every DB error, independent of the
383                    // rate-limited `error!` line below.
384                    micromegas_tracing::imetric!(
385                        "db_api_key_error_count",
386                        "count",
387                        table = table.table_name(),
388                        1_u64
389                    );
390                    maybe_log_error(&last_logged_at, window_secs, table.table_name(), &err);
391                    LookupError::Db(err)
392                })?;
393
394                match row {
395                    Some(row) => {
396                        let key_id: uuid::Uuid = row.try_get("key_id").map_err(|e| {
397                            LookupError::Db(anyhow::Error::from(e).context("reading key_id"))
398                        })?;
399                        let name: String = row.try_get("name").map_err(|e| {
400                            LookupError::Db(anyhow::Error::from(e).context("reading name"))
401                        })?;
402                        let audience: Option<String> = if table.has_audience() {
403                            Some(row.try_get("audience").map_err(|e| {
404                                LookupError::Db(anyhow::Error::from(e).context("reading audience"))
405                            })?)
406                        } else {
407                            None
408                        };
409                        // A nullable `TEXT[]` decodes into `Option<Vec<String>>`, not a bare
410                        // `Vec<String>` -- sqlx returns `UnexpectedNull` on a bare `Vec<String>`
411                        // target when the column is `NULL`, which every pre-v11 row is. `None`
412                        // is treated as an empty slice before handing it to `IpAllowlist::parse`.
413                        let allowed_cidrs: Option<Vec<String>> =
414                            row.try_get("allowed_cidrs").map_err(|e| {
415                                let err = anyhow::Error::from(e).context(format!(
416                                    "reading allowed_cidrs for key_id={key_id} in {}",
417                                    table.table_name()
418                                ));
419                                micromegas_tracing::error!(
420                                    "db_api_key store: malformed row (table={}): {err:#}",
421                                    table.table_name()
422                                );
423                                LookupError::Invalid(err)
424                            })?;
425                        let allowlist =
426                            IpAllowlist::parse(allowed_cidrs.as_deref().unwrap_or_default())
427                                .map_err(|e| {
428                                    let err = e.context(format!(
429                                        "parsing allowed_cidrs for key_id={key_id} in {}",
430                                        table.table_name()
431                                    ));
432                                    micromegas_tracing::error!(
433                                        "db_api_key store: malformed row (table={}): {err:#}",
434                                        table.table_name()
435                                    );
436                                    LookupError::Invalid(err)
437                                })?;
438                        Ok(Arc::new(KeyRow {
439                            key_id,
440                            name,
441                            audience,
442                            allowlist,
443                        }))
444                    }
445                    None => Err(LookupError::NotFound),
446                }
447            })
448            .await;
449
450        match result {
451            Ok(row) => {
452                // Checked after a cache hit or a fresh load, before returning `Ok(AuthContext)`.
453                // Deliberately not cached in `self.unknown` (that cache means "no such live
454                // key", not "this key exists but this caller may not use it from here") and
455                // must not increment `db_api_key_error_count` (that metric means "the DB was
456                // unreachable", not "a credential was rejected") -- both untouched, since this
457                // runs after the `try_get_with` closure above already returned successfully.
458                if !row.allowlist.allows(parts.client_ip()) {
459                    micromegas_tracing::warn!(
460                        "db api key rejected by allowlist: table={} key_id={} name={} client_ip={:?}",
461                        self.table.table_name(),
462                        row.key_id,
463                        row.name,
464                        parts.client_ip()
465                    );
466                    return Err(anyhow!("invalid API token: source IP not permitted"));
467                }
468                micromegas_tracing::trace!(
469                    "db api key validated: table={} key_id={} name={}",
470                    self.table.table_name(),
471                    row.key_id,
472                    row.name
473                );
474                Ok(AuthContext {
475                    subject: row.name.clone(),
476                    // Deliberately left `None`, not `created_by`: under the grant-map model,
477                    // `email` is what `user:` selectors match on, so populating it from the
478                    // minting admin would hand this key every audience granted to *them*.
479                    email: None,
480                    issuer: "api_key".to_string(),
481                    audience: None,
482                    expires_at: None,
483                    auth_type: AuthType::ApiKey,
484                    // `false` for ingestion keys: an ingestion write credential is not a
485                    // delegating service account, and can never reach the gRPC path
486                    // `allow_delegation` governs anyway (it lives in the other table).
487                    // Unchanged (`true`) for analytics keys.
488                    allow_delegation: self.table.allows_delegation(),
489                    // `Some(..)` for every ingestion key (the column is `NOT NULL`), `None`
490                    // for analytics keys -- `read_audiences` populates that side instead.
491                    bound_audience: row.audience.clone(),
492                    read_audiences: vec![],
493                    // API keys carry no email for a `user:` member to match, so only a
494                    // wildcard-admin group could ever make one admin.
495                    memberships: Arc::from([]),
496                })
497            }
498            // A DB *error* is propagated and cached in neither map: caching an
499            // outage as `unknown` would turn a transient failure into a
500            // TTL-long outage for every affected key.
501            Err(arc_err) => match arc_err.as_ref() {
502                // `Invalid` is already logged (with `key_id`) where it's raised, above --
503                // this arm only needs to render the generic client-facing rejection and stop
504                // the malformed row from being re-queried on every subsequent request.
505                LookupError::NotFound | LookupError::Invalid(_) => {
506                    self.unknown.insert(hash, ()).await;
507                    Err(anyhow!("invalid API token"))
508                }
509                LookupError::Db(e) => Err(ProviderUnavailable(anyhow!(
510                    "{} key store unavailable: {e:#}",
511                    self.table.table_name()
512                ))
513                .into()),
514            },
515        }
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    //! `KeyRow` and the `valid`/`unknown` caches are private, so the no-DB allowlist coverage
522    //! the plan called for lives here rather than in `tests/db_api_key_tests.rs` -- there is no
523    //! public seam from that integration-test crate to pre-populate a cache entry directly.
524    //! Mirrors that file's "no DB" pattern: a lazily-connected pool that is never actually
525    //! queried, since the cache is pre-populated and the loader closure never runs.
526
527    use super::*;
528    use crate::policy::PUBLIC_AUDIENCE;
529    use crate::types::{HttpRequestParts, RequestParts};
530    use micromegas_tracing::event::in_memory_sink::InMemorySink;
531    use micromegas_tracing::metrics::MetricsMsgQueueAny;
532    use micromegas_tracing::test_utils::init_in_memory_tracing;
533    use micromegas_transit::HeterogeneousQueue;
534    use serial_test::serial;
535    use sqlx::postgres::PgPoolOptions;
536    use std::net::{IpAddr, Ipv4Addr};
537
538    fn unreachable_pool() -> PgPool {
539        PgPoolOptions::new()
540            .acquire_timeout(Duration::from_millis(50))
541            .connect_lazy("postgres://localhost/unused")
542            .expect("lazy pool creation is infallible")
543    }
544
545    fn test_config() -> DbApiKeyConfig {
546        DbApiKeyConfig {
547            cache_size: 100,
548            cache_ttl_secs: 60,
549            unknown_cache_ttl_secs: 10,
550            unknown_cache_size: 100,
551        }
552    }
553
554    fn bearer_parts(token: &str, client_ip: Option<IpAddr>) -> HttpRequestParts {
555        let mut headers = http::HeaderMap::new();
556        headers.insert(
557            http::header::AUTHORIZATION,
558            format!("Bearer {token}").parse().expect("valid header"),
559        );
560        HttpRequestParts {
561            headers,
562            method: http::Method::GET,
563            uri: "/test".parse().expect("valid uri"),
564            client_ip,
565        }
566    }
567
568    fn count_integer_metric(sink: &InMemorySink, name: &str) -> u64 {
569        let state = sink.state.lock().expect("sink lock");
570        let mut count = 0u64;
571        for block in &state.metrics_blocks {
572            for evt in block.events.iter() {
573                match evt {
574                    MetricsMsgQueueAny::IntegerMetricEvent(e) if e.desc.name == name => count += 1,
575                    MetricsMsgQueueAny::TaggedIntegerMetricEvent(e) if e.desc.name == name => {
576                        count += 1
577                    }
578                    _ => {}
579                }
580            }
581        }
582        count
583    }
584
585    /// Pre-populates the `valid` cache with a canned `KeyRow` carrying a populated
586    /// `allowed_cidrs`-derived `IpAllowlist`, bypassing `try_get_with`'s loader (and therefore
587    /// the DB) entirely -- `validate_request` only ever reaches the DB on a cache miss.
588    async fn provider_with_cached_allowlisted_key(
589        token: &str,
590        allowed_cidrs: &[&str],
591    ) -> DbApiKeyAuthProvider {
592        let provider =
593            DbApiKeyAuthProvider::new(unreachable_pool(), ApiKeyTable::Ingestion, test_config());
594        let allowlist = IpAllowlist::parse(
595            &allowed_cidrs
596                .iter()
597                .map(|s| s.to_string())
598                .collect::<Vec<_>>(),
599        )
600        .expect("valid CIDR");
601        provider
602            .valid
603            .insert(
604                hash_key(token),
605                Arc::new(KeyRow {
606                    key_id: uuid::Uuid::new_v4(),
607                    name: "allowlisted-test-key".to_string(),
608                    audience: Some(PUBLIC_AUDIENCE.to_string()),
609                    allowlist,
610                }),
611            )
612            .await;
613        provider
614    }
615
616    #[tokio::test]
617    #[serial]
618    async fn allowlisted_key_authenticates_from_in_range_ip_without_db_access() {
619        let token = "mmk_allowlist_in_range";
620        let provider = provider_with_cached_allowlisted_key(token, &["10.0.0.0/24"]).await;
621        let parts = bearer_parts(token, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))));
622
623        let result = provider.validate_request(&parts as &dyn RequestParts).await;
624        assert!(result.is_ok(), "in-range IP must be permitted");
625    }
626
627    #[tokio::test]
628    #[serial]
629    async fn allowlisted_key_is_rejected_from_out_of_range_ip_without_db_access() {
630        let guard = init_in_memory_tracing();
631        let token = "mmk_allowlist_out_of_range";
632        let provider = provider_with_cached_allowlisted_key(token, &["10.0.0.0/24"]).await;
633        let parts = bearer_parts(token, Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
634
635        let result = provider.validate_request(&parts as &dyn RequestParts).await;
636        assert!(result.is_err(), "out-of-range IP must be rejected");
637
638        // Rejection on an allowlist mismatch is a distinct case from "no such live key" (which
639        // populates `unknown`) and from a DB outage (which increments the error metric) -- this
640        // is neither, so both must stay untouched.
641        assert!(
642            provider.unknown.get(&hash_key(token)).await.is_none(),
643            "an allowlist rejection must not poison the `unknown` cache for a key that exists"
644        );
645        micromegas_tracing::dispatch::flush_metrics_buffer();
646        assert_eq!(
647            count_integer_metric(&guard.sink, "db_api_key_error_count"),
648            0,
649            "an allowlist rejection is not a DB error and must not increment the error metric"
650        );
651    }
652
653    #[tokio::test]
654    #[serial]
655    async fn allowlisted_key_is_rejected_when_client_ip_is_unresolved() {
656        let token = "mmk_allowlist_no_ip";
657        let provider = provider_with_cached_allowlisted_key(token, &["10.0.0.0/24"]).await;
658        let parts = bearer_parts(token, None);
659
660        let result = provider.validate_request(&parts as &dyn RequestParts).await;
661        assert!(
662            result.is_err(),
663            "an unresolved client IP must never satisfy a non-empty allowlist"
664        );
665    }
666}