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}