Skip to content

Commit e08be07

Browse files
authored
feat(auth): Add support for multiple api keys (#307)
1 parent c02a01e commit e08be07

File tree

9 files changed

+100
-25
lines changed

9 files changed

+100
-25
lines changed

etl-api/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This API service provides a RESTful interface for managing Postgres replication
2323
- [Prerequisites](#prerequisites)
2424
- [Development](#development)
2525
- [Environment Variables](#environment-variables)
26+
- [Authentication](#authentication)
2627

2728
## Prerequisites
2829

@@ -80,3 +81,16 @@ After making changes to the database schema, update the SQLx metadata:
8081
```bash
8182
cargo sqlx prepare
8283
```
84+
85+
## Authentication
86+
87+
- The API uses Bearer token auth via the `Authorization` header.
88+
- Configure authentication with `api_keys` (each is base64 of 32 random bytes). All listed keys are accepted, enabling seamless key rotation.
89+
90+
Config example (YAML):
91+
92+
```yaml
93+
api_keys:
94+
- XOUbHmWbt9h7nWl15wWwyWQnctmFGNjpawMc3lT5CFs=
95+
- h1QqT7u+8t4q0t3m8rjOa2qK7F8w6h9C1xYzPqL7pmc=
96+
```

etl-api/configuration/dev.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ application:
1414
encryption_key:
1515
id: 0
1616
key: BlK9AlrzqRnCZy53j42uE1p2qGBiF7HYZjZYFaZObqg=
17-
api_key: XOUbHmWbt9h7nWl15wWwyWQnctmFGNjpawMc3lT5CFs=
17+
api_keys:
18+
- XOUbHmWbt9h7nWl15wWwyWQnctmFGNjpawMc3lT5CFs=
19+
- jD3rU2aYq7nVp1mWb4sTxQ9PZkHcGdR8eM5oLfNhJ0s=

etl-api/src/authentication.rs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,43 @@ pub async fn auth_validator(
2121
.unwrap_or_default()
2222
.scope("v1");
2323

24-
let api_key = req
24+
let api_config = req
2525
.app_data::<Data<ApiConfig>>()
26-
.expect("missing api configuration")
27-
.api_key
28-
.as_str();
26+
.expect("Missing API configuration while doing authentication");
2927

3028
let token = credentials.token();
31-
32-
let api_key: ApiKey = match api_key.try_into() {
33-
Ok(api_key) => api_key,
29+
let token: ApiKey = match token.try_into() {
30+
Ok(token) => token,
3431
Err(_) => {
3532
return Err((AuthenticationError::from(config).into(), req));
3633
}
3734
};
3835

39-
let token: ApiKey = match token.try_into() {
40-
Ok(token) => token,
41-
Err(_) => {
36+
// Decode all configured API keys (rotation supported via multiple entries).
37+
let configured_keys: Vec<ApiKey> = {
38+
let keys = &api_config.api_keys;
39+
if keys.is_empty() {
4240
return Err((AuthenticationError::from(config).into(), req));
4341
}
42+
43+
let mut configured_keys = Vec::with_capacity(keys.len());
44+
for key in keys {
45+
match key.as_str().try_into() {
46+
Ok(k) => configured_keys.push(k),
47+
Err(_) => return Err((AuthenticationError::from(config).into(), req)),
48+
}
49+
}
50+
51+
configured_keys
4452
};
4553

46-
if !constant_time_eq_n(&api_key.key, &token.key) {
54+
// Compare against all configured keys without an early exit to avoid timing leaks.
55+
let mut valid = false;
56+
for key in &configured_keys {
57+
valid |= constant_time_eq_n(&key.key, &token.key);
58+
}
59+
60+
if !valid {
4761
return Err((AuthenticationError::from(config).into(), req));
4862
}
4963

etl-api/src/config.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use base64::{Engine, prelude::BASE64_STANDARD};
2+
use etl_config::Config;
23
use etl_config::shared::{PgConnectionConfig, SentryConfig};
34
use serde::de::{MapAccess, Visitor};
45
use serde::{Deserialize, Deserializer, de};
@@ -20,12 +21,18 @@ pub struct ApiConfig {
2021
pub application: ApplicationSettings,
2122
/// Encryption key configuration.
2223
pub encryption_key: EncryptionKey,
23-
/// Base64-encoded API key string.
24-
pub api_key: String,
24+
/// List of base64-encoded API keys.
25+
///
26+
/// All keys in this list are considered valid for authentication.
27+
pub api_keys: Vec<String>,
2528
/// Optional Sentry configuration for error tracking.
2629
pub sentry: Option<SentryConfig>,
2730
}
2831

32+
impl Config for ApiConfig {
33+
const LIST_PARSE_KEYS: &'static [&'static str] = &["api_keys"];
34+
}
35+
2936
/// HTTP server configuration settings.
3037
#[derive(Debug, Clone, Deserialize)]
3138
pub struct ApplicationSettings {

etl-api/tests/common/test_app.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use etl_api::{
2121
use etl_config::shared::PgConnectionConfig;
2222
use etl_config::{Environment, load_config};
2323
use etl_postgres::sqlx::test_utils::drop_pg_database;
24+
use rand::random_range;
2425
use reqwest::{IntoUrl, RequestBuilder};
2526
use std::io;
2627
use std::net::TcpListener;
@@ -509,7 +510,11 @@ pub async fn spawn_test_app() -> TestApp {
509510

510511
let key = generate_random_key::<32>().expect("failed to generate random key");
511512
let encryption_key = encryption::EncryptionKey { id: 0, key };
512-
let api_key = "XOUbHmWbt9h7nWl15wWwyWQnctmFGNjpawMc3lT5CFs=".to_string();
513+
514+
// We choose a random API key from the ones configured to show that rotation works.
515+
let api_key_index = random_range(0..config.api_keys.len());
516+
let api_key = config.api_keys[api_key_index].clone();
517+
513518
let k8s_client = Some(Arc::new(MockK8sClient) as Arc<dyn K8sClient>);
514519

515520
let server = run(

etl-api/tests/integration/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ mod destinations_pipelines_test;
33
mod etl_tables_missing_test;
44
mod health_check_test;
55
mod images_test;
6+
mod metrics_test;
67
mod pipelines_test;
78
mod sources_test;
89
mod tenants_sources_test;
910
mod tenants_test;
10-
11-
mod metrics_test;

etl-config/src/load.rs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,34 @@ const ENV_PREFIX_SEPARATOR: &str = "_";
1919
/// Example: `APP_DATABASE__URL` sets the `database.url` field.
2020
const ENV_SEPARATOR: &str = "__";
2121

22+
/// Separator for list elements in environment variables.
23+
///
24+
/// Example: `APP_API_KEYS=abc,def` sets the `api_keys` array field.
25+
const LIST_SEPARATOR: &str = ",";
26+
27+
/// Trait defining the list of keys that should be parsed as lists in a given [`Config`]
28+
/// implementation.
29+
pub trait Config {
30+
/// Slice containing all the keys that should be parsed as lists when loading the configuration.
31+
const LIST_PARSE_KEYS: &'static [&'static str];
32+
}
33+
2234
/// Loads hierarchical configuration from YAML files and environment variables.
2335
///
2436
/// Loads configuration in this order:
2537
/// 1. Base configuration from `configuration/base.yaml`
2638
/// 2. Environment-specific file from `configuration/{environment}.yaml`
2739
/// 3. Environment variable overrides prefixed with `APP`
2840
///
29-
/// Nested keys use double underscores: `APP_DATABASE__URL` → `database.url`
41+
/// Nested keys use double underscores: `APP_DATABASE__URL` → `database.url` and lists are separated
42+
/// by `,`.
3043
///
3144
/// # Panics
3245
/// Panics if current directory cannot be determined or if `APP_ENVIRONMENT`
3346
/// cannot be parsed.
3447
pub fn load_config<T>() -> Result<T, config::ConfigError>
3548
where
36-
T: DeserializeOwned,
49+
T: Config + DeserializeOwned,
3750
{
3851
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
3952
let configuration_directory = base_path.join(CONFIGURATION_DIR);
@@ -43,21 +56,33 @@ where
4356
let environment = Environment::load().expect("Failed to parse APP_ENVIRONMENT.");
4457

4558
let environment_filename = format!("{environment}.yaml");
59+
60+
// We build the environment configuration source.
61+
let mut environment_source = config::Environment::with_prefix(ENV_PREFIX)
62+
.prefix_separator(ENV_PREFIX_SEPARATOR)
63+
.separator(ENV_SEPARATOR)
64+
.try_parsing(true)
65+
.list_separator(LIST_SEPARATOR);
66+
67+
// For all the list parse keys, we add them to the environment source. These are used to define
68+
// which keys should be parsed as lists.
69+
for key in <T as Config>::LIST_PARSE_KEYS {
70+
environment_source = environment_source.with_list_parse_key(key);
71+
}
72+
4673
let settings = config::Config::builder()
74+
// Add in settings from the base configuration file.
4775
.add_source(config::File::from(
4876
configuration_directory.join(BASE_CONFIG_FILE),
4977
))
78+
// Add in settings from the environment-specific file.
5079
.add_source(config::File::from(
5180
configuration_directory.join(environment_filename),
5281
))
5382
// Add in settings from environment variables (with a prefix of APP and '__' as separator)
5483
// E.g. `APP_DESTINATION__BIG_QUERY__PROJECT_ID=my-project-id` sets
5584
// `Settings { destination: BigQuery { project_id } }` to `my-project-id`.
56-
.add_source(
57-
config::Environment::with_prefix(ENV_PREFIX)
58-
.prefix_separator(ENV_PREFIX_SEPARATOR)
59-
.separator(ENV_SEPARATOR),
60-
)
85+
.add_source(environment_source)
6186
.build()?;
6287

6388
settings.try_deserialize::<T>()

etl-config/src/shared/connection.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
33
use sqlx::postgres::{PgConnectOptions as SqlxConnectOptions, PgSslMode as SqlxSslMode};
44
use tokio_postgres::{Config as TokioPgConnectOptions, config::SslMode as TokioPgSslMode};
55

6-
use crate::SerializableSecretString;
76
use crate::shared::ValidationError;
7+
use crate::{Config, SerializableSecretString};
88

99
/// Postgres server options for ETL workloads.
1010
///
@@ -133,6 +133,10 @@ pub struct PgConnectionConfig {
133133
pub tls: TlsConfig,
134134
}
135135

136+
impl Config for PgConnectionConfig {
137+
const LIST_PARSE_KEYS: &'static [&'static str] = &[];
138+
}
139+
136140
/// TLS configuration for secure Postgres connections.
137141
#[derive(Debug, Clone, Serialize, Deserialize)]
138142
pub struct TlsConfig {

etl-config/src/shared/replicator.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::Config;
12
use crate::shared::pipeline::PipelineConfig;
23
use crate::shared::{DestinationConfig, SentryConfig, SupabaseConfig, ValidationError};
34
use serde::{Deserialize, Serialize};
@@ -33,3 +34,7 @@ impl ReplicatorConfig {
3334
self.pipeline.validate()
3435
}
3536
}
37+
38+
impl Config for ReplicatorConfig {
39+
const LIST_PARSE_KEYS: &'static [&'static str] = &[];
40+
}

0 commit comments

Comments
 (0)