Skip to main content

micromegas_auth/
default_provider.rs

1//! Default authentication provider initialization for Micromegas services.
2//!
3//! This module provides the standard way to initialize authentication with
4//! OIDC and a DB-backed API key store from environment variables.
5
6use crate::db_api_key::{
7    ApiKeyTable, DbApiKeyAuthProvider, DbApiKeyConfig, key_store_has_live_rows,
8};
9use crate::env::{
10    resolve_prefixed_var, warn_removed_api_key_vars, warn_removed_audience_grant_vars,
11};
12use crate::groups::{DbGroupsConfig, DbGroupsSource};
13use crate::membership::MembershipProvider;
14use crate::multi::MultiAuthProvider;
15use crate::oidc::{OidcAuthProvider, OidcConfig};
16use crate::types::AuthProvider;
17use anyhow::Result;
18use micromegas_tracing::{info, warn};
19use sqlx::PgPool;
20use std::sync::Arc;
21use std::time::Duration;
22
23/// Builder for the default (env-driven) authentication provider stack, plus an
24/// optional DB-backed API key store and an optional local-group store.
25pub struct ProviderBuilder {
26    prefix: String,
27    key_store: Option<(PgPool, ApiKeyTable)>,
28    group_store: Option<PgPool>,
29}
30
31impl ProviderBuilder {
32    /// Starts a builder scoped to `prefix` (e.g. `"MICROMEGAS_INGESTION"`, or `""`
33    /// for the unprefixed default), following the same `{prefix}_*`-with-fallback
34    /// convention as [`resolve_prefixed_var`].
35    pub fn new(prefix: &str) -> Self {
36        Self {
37            prefix: prefix.to_string(),
38            key_store: None,
39            group_store: None,
40        }
41    }
42
43    /// Attaches a DB-backed key store bound to `table`, looked up through `pool`
44    /// (expected to be a [`crate::db_api_key::dedicated_key_store_pool`], not a
45    /// clone of the caller's lake pool).
46    pub fn with_db_key_store(mut self, pool: PgPool, table: ApiKeyTable) -> Self {
47        self.key_store = Some((pool, table));
48        self
49    }
50
51    /// Attaches a local-group store, looked up through `pool` (same dedicated-pool
52    /// expectation as [`Self::with_db_key_store`]). `compose()` wraps the finished chain in a
53    /// [`MembershipProvider`] when this is attached.
54    pub fn with_group_store(mut self, pool: PgPool) -> Self {
55        self.group_store = Some(pool);
56        self
57    }
58
59    /// Resolves the OIDC config env var name for this builder's prefix, with
60    /// fallback to the unprefixed name.
61    fn oidc_config_var(&self) -> String {
62        resolve_prefixed_var(&self.prefix, "OIDC_CONFIG")
63    }
64
65    /// Composes the chain and reports whether OIDC counted as "configured". Shared by `build()`
66    /// and `build_chain()`, which each document the provider order and the DB-provider
67    /// guarantee.
68    ///
69    /// Takes `&self` rather than `self` so `build()` can still reach the
70    /// attached key store's pool for its existence query afterwards.
71    async fn compose(&self) -> Result<(MultiAuthProvider, bool)> {
72        warn_removed_api_key_vars();
73        warn_removed_audience_grant_vars();
74
75        let oidc_config_var = self.oidc_config_var();
76
77        let mut multi = MultiAuthProvider::new();
78        let mut configured = false;
79
80        match OidcConfig::from_env_var(&oidc_config_var) {
81            Ok(config) => {
82                info!("Initializing OIDC authentication");
83                let oidc_provider = OidcAuthProvider::new(config).await?;
84                multi = multi.with_provider(Arc::new(oidc_provider));
85                configured = true;
86            }
87            Err(e) => {
88                info!("OIDC not configured ({e}) - OIDC auth disabled");
89            }
90        }
91
92        if let Some((pool, table)) = &self.key_store {
93            let db_config = DbApiKeyConfig::from_env_with_prefix(&self.prefix);
94            let db_provider = DbApiKeyAuthProvider::new(pool.clone(), *table, db_config);
95            multi = multi.with_provider(Arc::new(db_provider));
96        }
97
98        Ok((multi, configured))
99    }
100
101    /// Wraps `multi` in a [`MembershipProvider`] when a group store is attached, and runs one
102    /// eager `current()` at startup: on `Ok`, `warn!`s while `has_wildcard_admin()` is true
103    /// ("every authenticated caller is an admin; add a `user:` member to `admins` and remove
104    /// `*`"); on `Err`, `warn!`s and continues -- the first request will 503, and a split
105    /// deployment may legitimately start flight-sql before the migration runner is up, as with
106    /// the v5 key store.
107    async fn wrap_with_membership(&self, multi: MultiAuthProvider) -> Arc<dyn AuthProvider> {
108        let Some(pool) = &self.group_store else {
109            return Arc::new(multi) as Arc<dyn AuthProvider>;
110        };
111        let group_config = DbGroupsConfig::from_env_with_prefix(&self.prefix);
112        let groups = Arc::new(DbGroupsSource::new(
113            pool.clone(),
114            Duration::from_secs(group_config.cache_ttl_secs),
115        ));
116        match groups.current().await {
117            Ok(graph) => {
118                if graph.has_wildcard_admin() {
119                    warn!(
120                        "every authenticated caller is an admin; add a `user:` member to \
121                         `admins` and remove `*`"
122                    );
123                }
124            }
125            Err(e) => {
126                warn!("group store not yet reachable at startup, continuing: {e:#}");
127            }
128        }
129        Arc::new(MembershipProvider::new(
130            Arc::new(multi) as Arc<dyn AuthProvider>,
131            groups,
132        )) as Arc<dyn AuthProvider>
133    }
134
135    /// Builds the composed provider.
136    ///
137    /// Composes, in order: `OidcAuthProvider` → `DbApiKeyAuthProvider`; `MultiAuthProvider` tries
138    /// providers in order, so putting the DB provider last means only tokens that are not a valid
139    /// JWT ever reach it. The DB provider is always pushed onto the chain whenever a key store is
140    /// attached, so a key minted into a previously empty table authenticates on the very next
141    /// request, with no restart.
142    ///
143    /// **A DB key store with at least one live key counts as "auth configured";
144    /// an empty one does not.** When a key store is attached, this runs one cheap
145    /// startup existence query (`key_store_has_live_rows`) and treats a non-empty
146    /// result the same as OIDC being present. A failure of that query
147    /// (e.g. a missing relation because the schema has not reached migration v5
148    /// yet) is propagated as an `Err` from `build()` — unless OIDC
149    /// already configured auth, in which case the failure is only `warn!`-logged
150    /// and `has_live_rows` is treated as `false`, since the query's result would
151    /// be unused either way.
152    ///
153    /// The chain is discarded when nothing counted as configured; use
154    /// `build_chain()` to skip the guard.
155    pub async fn build(self) -> Result<Option<Arc<dyn AuthProvider>>> {
156        let (multi, mut configured) = self.compose().await?;
157
158        if let Some((pool, table)) = &self.key_store {
159            let has_live_rows = match key_store_has_live_rows(pool, *table).await {
160                Ok(has_live_rows) => has_live_rows,
161                Err(e) if !configured => {
162                    return Err(e.context(format!(
163                        "checking whether {} has any live key — has the schema reached migration v5? \
164                         (rust/ingestion/src/sql_migration.rs; the ingestion binary or monolith must run \
165                         the migration before flight-sql starts in a split deployment)",
166                        table.table_name()
167                    )));
168                }
169                Err(e) => {
170                    // Auth is already configured via another provider (OIDC); this
171                    // query's only purpose is deciding whether an otherwise-unconfigured
172                    // deployment counts as configured, so a failure here (e.g. schema
173                    // not yet at v5) must not abort startup.
174                    warn!(
175                        "checking whether {} has any live key failed, ignoring (auth already configured): {e:#}",
176                        table.table_name()
177                    );
178                    false
179                }
180            };
181            if has_live_rows {
182                configured = true;
183            }
184        }
185
186        if !configured {
187            return Ok(None);
188        }
189
190        Ok(Some(self.wrap_with_membership(multi).await))
191    }
192
193    /// Composes the provider chain with **no** "is anything configured?" guard.
194    ///
195    /// Composes, in order: `OidcAuthProvider` → `DbApiKeyAuthProvider`. The DB provider is always
196    /// pushed onto the chain whenever a key store is attached, so a key minted into a previously
197    /// empty table authenticates on the very next request, with no restart.
198    ///
199    /// Always returns the chain — never `None`, because there is no `Option` —
200    /// and never runs `key_store_has_live_rows`, so unlike `build()` it cannot
201    /// fail on a schema short of migration v5. This is the entry point for a
202    /// caller that wants the no-restart property above without inheriting
203    /// `build()`'s startup guard or its existence-query failure mode — e.g. an
204    /// embedder folding this chain into a larger `MultiAuthProvider` via
205    /// `FlightSqlServer::with_auth_provider`.
206    ///
207    /// When nothing at all is configured (no OIDC, no key store)
208    /// the returned chain is an empty `MultiAuthProvider`, which rejects every
209    /// request — fail-closed, since the caller asked for no guard. Logs a
210    /// `warn!` in that case, since an empty chain is useless to any caller.
211    pub async fn build_chain(self) -> Result<Arc<dyn AuthProvider>> {
212        let (multi, _) = self.compose().await?;
213        if multi.is_empty() {
214            warn!("no auth provider configured: OIDC and DB key store are both absent");
215        }
216        Ok(self.wrap_with_membership(multi).await)
217    }
218}