Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
- Added a note on webserver workers to the trouble-shooting section ([#685]).
- Helm: Allow Pod `priorityClassName` to be configured ([#687]).

### Changed

- Use internal secrets for secret- and jwt-keys ([#686]).

### Fixed

- Don't panic on invalid authorization config. Previously, a missing OPA ConfigMap would crash the operator ([#667]).
Expand All @@ -25,6 +29,7 @@
[#679]: https://github.com/stackabletech/airflow-operator/pull/679
[#683]: https://github.com/stackabletech/airflow-operator/pull/683
[#685]: https://github.com/stackabletech/airflow-operator/pull/685
[#686]: https://github.com/stackabletech/airflow-operator/pull/686
[#687]: https://github.com/stackabletech/airflow-operator/pull/687

## [25.7.0] - 2025-07-23
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion Cargo.nix

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ product-config = { git = "https://github.com/stackabletech/product-config.git",
stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", features = ["telemetry", "versioned"], tag = "stackable-operator-0.95.0" }

anyhow = "1.0"
base64 = "0.22"
built = { version = "0.8", features = ["chrono", "git2"] }
clap = "4.5"
const_format = "0.2"
fnv = "1.0"
futures = { version = "0.3", features = ["compat"] }
indoc = "2.0"
rand = "0.9.0"
rstest = "0.26"
semver = "1.0"
serde = { version = "1.0", features = ["derive"] }
Expand Down
1 change: 0 additions & 1 deletion docs/modules/airflow/examples/example-airflow-secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ stringData:
adminUser.lastname: Admin
adminUser.email: airflow@airflow.com
adminUser.password: airflow
connections.secretKey: thisISaSECRET_1234
connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
# Only needed when using celery workers (instead of Kubernetes executors)
connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ stringData:
adminUser.lastname: Admin
adminUser.email: airflow@airflow.com
adminUser.password: airflow
connections.secretKey: thisISaSECRET_1234
connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
# Only needed when using celery workers (instead of Kubernetes executors)
connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
Expand Down
3 changes: 0 additions & 3 deletions docs/modules/airflow/pages/getting_started/first_steps.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ And apply it:
[source,bash]
include::example$getting_started/code/getting_started.sh[tag=apply-airflow-credentials]

The `connections.secretKey` is used for securely signing the session cookies and can be used for any other security related needs by extensions.
It should be a long random string of bytes.

`connections.sqlalchemyDatabaseUri` must contain the connection string to the SQL database storing the Airflow metadata.

`connections.celeryResultBackend` must contain the connection string to the SQL database storing the job metadata (the example above uses the same PostgreSQL database for both).
Expand Down
1 change: 0 additions & 1 deletion examples/simple-airflow-cluster-dags-cmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ stringData:
adminUser.lastname: Admin
adminUser.email: airflow@airflow.com
adminUser.password: airflow
connections.secretKey: thisISaSECRET_1234
connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
# Only needed when using celery workers (instead of Kubernetes executors)
connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
Expand Down
1 change: 0 additions & 1 deletion examples/simple-airflow-cluster-ldap-insecure-tls.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ stringData:
adminUser.lastname: Admin
adminUser.email: airflow@airflow.com
adminUser.password: airflow
connections.secretKey: thisISaSECRET_1234
connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
# Only needed when using celery workers (instead of Kubernetes executors)
connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
Expand Down
1 change: 0 additions & 1 deletion examples/simple-airflow-cluster-ldap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ stringData:
adminUser.lastname: Admin
adminUser.email: airflow@airflow.com
adminUser.password: airflow
connections.secretKey: thisISaSECRET_1234
connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
# Only needed when using celery workers (instead of Kubernetes executors)
connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
Expand Down
1 change: 0 additions & 1 deletion examples/simple-airflow-cluster.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ stringData:
adminUser.lastname: Admin
adminUser.email: airflow@airflow.com
adminUser.password: airflow
connections.secretKey: thisISaSECRET_1234
connections.sqlalchemyDatabaseUri: postgresql+psycopg2://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
# Only needed when using celery workers (instead of Kubernetes executors)
connections.celeryResultBackend: db+postgresql://airflow:airflow@airflow-postgresql.default.svc.cluster.local/airflow
Expand Down
4 changes: 3 additions & 1 deletion rust/operator-binary/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ product-config.workspace = true
stackable-operator.workspace = true

anyhow.workspace = true
base64.workspace = true
clap.workspace = true
const_format.workspace = true
fnv.workspace = true
futures.workspace = true
indoc.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
snafu.workspace = true
strum.workspace = true
tokio.workspace = true
tracing.workspace = true
indoc.workspace = true

[build-dependencies]
built.workspace = true
Expand Down
27 changes: 26 additions & 1 deletion rust/operator-binary/src/airflow_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ use crate::{
AirflowAuthenticationClassResolved, AirflowClientAuthenticationDetailsResolved,
},
authorization::AirflowAuthorizationResolved,
build_recommended_labels, v1alpha1,
build_recommended_labels,
internal_secret::{ENV_INTERNAL_SECRET, ENV_JWT_SECRET, create_random_secret},
v1alpha1,
},
env_vars::{self, build_airflow_template_envs},
operations::{
Expand Down Expand Up @@ -346,6 +348,9 @@ pub enum Error {
ResolveProductImage {
source: product_image_selection::Error,
},

#[snafu(display("failed to create internal secret"))]
InvalidInternalSecret { source: crd::internal_secret::Error },
}

type Result<T, E = Error> = std::result::Result<T, E>;
Expand Down Expand Up @@ -470,6 +475,26 @@ pub async fn reconcile_airflow(
.await?;
}

create_random_secret(
airflow.shared_internal_secret_name().as_ref(),
ENV_INTERNAL_SECRET,
256,
airflow,
client,
)
.await
.context(InvalidInternalSecretSnafu)?;

create_random_secret(
airflow.shared_jwt_secret_name().as_ref(),
ENV_JWT_SECRET,
256,
airflow,
client,
)
.await
.context(InvalidInternalSecretSnafu)?;

for (role_name, role_config) in validated_role_config.iter() {
let airflow_role =
AirflowRole::from_str(role_name).context(UnidentifiedAirflowRoleSnafu {
Expand Down
116 changes: 116 additions & 0 deletions rust/operator-binary/src/crd/internal_secret.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use std::collections::BTreeMap;

use base64::{Engine as _, engine::general_purpose};
use rand::{TryRngCore, rand_core::OsError, rngs::OsRng};
use snafu::{OptionExt, ResultExt, Snafu};
use stackable_operator::{
builder::meta::ObjectMetaBuilder, client::Client, k8s_openapi::api::core::v1::Secret,
kube::ResourceExt, logging::controller::ReconcilerError,
};
use strum::{EnumDiscriminants, IntoStaticStr};

use crate::{airflow_controller::AIRFLOW_CONTROLLER_NAME, crd::v1alpha1};

// Used for env-vars: AIRFLOW__WEBSERVER__SECRET_KEY, AIRFLOW__API__SECRET_KEY
// N.B. AIRFLOW__WEBSERVER__SECRET_KEY is deprecated as of 3.0.2.
// Secret key used to run the api server. It should be as random as possible.
// It should be consistent across instances of the webserver. The webserver key
// is also used to authorize requests to Celery workers when logs are retrieved.
pub const ENV_INTERNAL_SECRET: &str = "INTERNAL_SECRET";
// Used for env-var: AIRFLOW__API_AUTH__JWT_SECRET
// Secret key used to encode and decode JWTs to authenticate to public and
// private APIs. It should be as random as possible, but consistent across
// instances of API services.
pub const ENV_JWT_SECRET: &str = "JWT_SECRET";

type Result<T, E = Error> = std::result::Result<T, E>;

impl ReconcilerError for Error {
fn category(&self) -> &'static str {
ErrorDiscriminants::from(self).into()
}
}

#[derive(Snafu, Debug, EnumDiscriminants)]
#[strum_discriminants(derive(IntoStaticStr))]
pub enum Error {
#[snafu(display("object defines no namespace"))]
ObjectHasNoNamespace,

#[snafu(display("object is missing metadata to build owner reference"))]
ObjectMissingMetadataForOwnerRef {
source: stackable_operator::builder::meta::Error,
},

#[snafu(display("failed to retrieve secret for internal communications"))]
FailedToRetrieveInternalSecret {
source: stackable_operator::client::Error,
},

#[snafu(display("failed to apply internal secret"))]
ApplyInternalSecret {
source: stackable_operator::client::Error,
},

#[snafu(display("object defines no namespace"))]
SeedRandomGenerator { source: OsError },
Comment thread
maltesander marked this conversation as resolved.
Outdated
}

pub async fn create_random_secret(
secret_name: &str,
secret_key: &str,
secret_byte_size: usize,
airflow: &v1alpha1::AirflowCluster,
client: &Client,
) -> Result<()> {
let mut internal_secret = BTreeMap::new();
internal_secret.insert(secret_key.to_string(), get_random_base64(secret_byte_size)?);

let secret = Secret {
immutable: Some(true),
metadata: ObjectMetaBuilder::new()
.name(secret_name)
.namespace_opt(airflow.namespace())
.ownerreference_from_resource(airflow, None, Some(true))
.context(ObjectMissingMetadataForOwnerRefSnafu)?
.build(),
string_data: Some(internal_secret),
..Secret::default()
};

if client
.get_opt::<Secret>(
&secret.name_any(),
secret
.namespace()
.as_deref()
.context(ObjectHasNoNamespaceSnafu)?,
)
.await
.context(FailedToRetrieveInternalSecretSnafu)?
.is_none()
{
client
.apply_patch(AIRFLOW_CONTROLLER_NAME, &secret, &secret)
.await
.context(ApplyInternalSecretSnafu)?;
}

Ok(())
}

fn get_random_base64(byte_size: usize) -> Result<String, Error> {
let mut buf = vec![0u8; byte_size];
// OsRng is a cryptographically secure pseudo-random number generator
// (CSPRNG) and also has no possible state to leak and cannot be
// improperly seeded. See: https://rust-random.github.io/book/guide-gen.html#cryptographically-secure-pseudo-random-number-generator
// and https://github.com/rust-random/rand/blob/master/SECURITY.md#specific-generators
// This call explicity returns a Result. An alternative would be to
// use let mut rng = StdRng::from_os_rng() and then use fill_bytes
// but this may *still* panic if the underlying (OS) mechanism fails
// for some reason, so keep the potential panic transparent.
OsRng
.try_fill_bytes(&mut buf)
.context(SeedRandomGeneratorSnafu)?;
Ok(general_purpose::STANDARD.encode(buf))
}
9 changes: 9 additions & 0 deletions rust/operator-binary/src/crd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ use crate::{
pub mod affinity;
pub mod authentication;
pub mod authorization;
pub mod internal_secret;

pub const APP_NAME: &str = "airflow";
pub const OPERATOR_NAME: &str = "airflow.stackable.tech";
Expand Down Expand Up @@ -452,6 +453,14 @@ impl v1alpha1::AirflowCluster {
tracing::debug!("Merged executor config: {:?}", conf_executor);
fragment::validate(conf_executor).context(FragmentValidationFailureSnafu)
}

pub fn shared_internal_secret_name(&self) -> String {
format!("{}-internal-secret", &self.name_any())
}

pub fn shared_jwt_secret_name(&self) -> String {
format!("{}-jwt-secret", &self.name_any())
}
}

fn extract_role_from_webserver_config(
Expand Down
Loading