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}