diff --git a/chart/docs/production-guide.rst b/chart/docs/production-guide.rst index 029a71e747822..8092d8a09ac6d 100644 --- a/chart/docs/production-guide.rst +++ b/chart/docs/production-guide.rst @@ -227,6 +227,41 @@ In the ``values.yaml`` below secret-related parameters should be adjusted like: # The maximum number of server connections to the result backend database from PgBouncer resultBackendPoolSize: 5 +Creating the first user +----------------------- + +The chart does not create an Airflow user for you. + +Earlier versions ran a create-user job by default that provisioned an ``admin`` account +with the password ``admin``. Those credentials were the same on every installation, so +anything able to reach the API server -- by default any workload in the cluster, since +``networkPolicies.enabled`` is ``false`` -- could sign in with the Admin role. + +``createUserJob`` is therefore disabled by default and the chart ships no default +username or password. Enabling the job without supplying both fails the render with a +message saying so, rather than creating an account with well-known credentials. + +Create the user yourself after installing: + +.. code-block:: bash + + kubectl exec -it deploy/-api-server -- \ + airflow users create -r Admin -u -e -f -l -p + +Or enable the job with credentials of your own: + +.. code-block:: yaml + + createUserJob: + enabled: true + defaultUser: + username: + password: + +Values files are frequently committed to source control, so where your deployment +tooling supports it, supply the password from a Kubernetes Secret rather than writing it +into ``values.yaml``. + API Secret Key -------------- diff --git a/chart/newsfragments/72209.significant.rst b/chart/newsfragments/72209.significant.rst new file mode 100644 index 0000000000000..1104a552356bb --- /dev/null +++ b/chart/newsfragments/72209.significant.rst @@ -0,0 +1,7 @@ +``createUserJob`` is now disabled by default and creates no default user. + +Earlier versions ran the job on every install and provisioned a FAB ``admin`` account with the password ``admin``. Those credentials were identical on every installation, so anything able to reach the API server could sign in with the Admin role. + +The chart now ships no default ``createUserJob.defaultUser.username`` or ``password``, and enabling the job without setting both fails the render rather than creating an account with well-known credentials. + +If you relied on the default account, either create the user yourself with ``airflow users create`` after installing, or set ``createUserJob.enabled: true`` together with a username and password of your own. See :doc:`production-guide` for both. diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 51b7690ed016b..b4cf2e0dc7051 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -51,9 +51,12 @@ Flower Dashboard: kubectl port-forward svc/{{ include "airflow.fullname" . {{- end }} {{- if .Values.createUserJob.enabled }} -Default user (Airflow UI) Login credentials: +User created by the create-user job (Airflow UI): username: {{ .Values.createUserJob.defaultUser.username }} - password: {{ .Values.createUserJob.defaultUser.password }} +{{- else }} +No Airflow UI user has been created. Create one with: + kubectl exec -it deploy/{{ include "airflow.fullname" . }}-api-server --namespace {{ .Release.Namespace }} -- \ + airflow users create -r Admin -u -e -f -l -p {{- end }} {{- if .Values.postgresql.enabled }} diff --git a/chart/templates/jobs/create-user-job-serviceaccount.yaml b/chart/templates/jobs/create-user-job-serviceaccount.yaml index 7b855f5edd633..90abd9fb33177 100644 --- a/chart/templates/jobs/create-user-job-serviceaccount.yaml +++ b/chart/templates/jobs/create-user-job-serviceaccount.yaml @@ -20,7 +20,7 @@ ######################################### ## Airflow Create User Job ServiceAccount ######################################### -{{- if .Values.createUserJob.serviceAccount.create }} +{{- if and .Values.createUserJob.enabled .Values.createUserJob.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount automountServiceAccountToken: {{ .Values.createUserJob.serviceAccount.automountServiceAccountToken }} diff --git a/chart/templates/jobs/create-user-job.yaml b/chart/templates/jobs/create-user-job.yaml index 2136bdc08f09e..d68b45b930073 100644 --- a/chart/templates/jobs/create-user-job.yaml +++ b/chart/templates/jobs/create-user-job.yaml @@ -21,6 +21,9 @@ ## Airflow Create User Job ########################## {{- if .Values.createUserJob.enabled }} +{{- if or (not .Values.createUserJob.defaultUser.username) (not .Values.createUserJob.defaultUser.password) }} +{{- fail "createUserJob.enabled is true but createUserJob.defaultUser.username and/or createUserJob.defaultUser.password are not set. Set both to credentials of your own, or leave createUserJob.enabled false and create the user yourself with `airflow users create`." }} +{{- end }} {{- $nodeSelector := or .Values.createUserJob.nodeSelector .Values.nodeSelector }} {{- $affinity := or .Values.createUserJob.affinity .Values.affinity }} {{- $tolerations := or .Values.createUserJob.tolerations .Values.tolerations }} diff --git a/chart/tests/helm_tests/airflow_aux/test_airflow_common.py b/chart/tests/helm_tests/airflow_aux/test_airflow_common.py index 0a5b15562797c..237b84ea180e4 100644 --- a/chart/tests/helm_tests/airflow_aux/test_airflow_common.py +++ b/chart/tests/helm_tests/airflow_aux/test_airflow_common.py @@ -220,6 +220,7 @@ def test_global_affinity_tolerations_topology_spread_constraints_and_node_select } ], "nodeSelector": {"type": "user-node"}, + "createUserJob": {"enabled": True, "defaultUser": {"username": "admin", "password": "admin"}}, }, show_only=[ "templates/cleanup/cleanup-cronjob.yaml", @@ -489,7 +490,11 @@ def test_priority_class_name(self): "cleanup": {"enabled": True, "priorityClassName": "low-priority-airflow-cleanup-pods"}, "databaseCleanup": {"enabled": True, "priorityClassName": "low-priority-database-cleanup"}, "migrateDatabaseJob": {"priorityClassName": "low-priority-run-airflow-migrations"}, - "createUserJob": {"priorityClassName": "low-priority-create-user-job"}, + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "priorityClassName": "low-priority-create-user-job", + }, }, show_only=[ "templates/flower/flower-deployment.yaml", diff --git a/chart/tests/helm_tests/airflow_aux/test_annotations.py b/chart/tests/helm_tests/airflow_aux/test_annotations.py index 5b2273ff2e235..09665272d8ab1 100644 --- a/chart/tests/helm_tests/airflow_aux/test_annotations.py +++ b/chart/tests/helm_tests/airflow_aux/test_annotations.py @@ -206,6 +206,8 @@ class TestServiceAccountAnnotations: ( { "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, "serviceAccount": { "annotations": { "example": "createuser", @@ -326,6 +328,13 @@ def test_tpl_rendered_annotations_airflow_3(self, values_key, show_only): **({"executor": "KubernetesExecutor"} if values_key == "cleanup" else {}), values_key: { "enabled": True, + # The create-user job refuses to run without credentials of the + # deployer's own, so supply them the way an install would. + **( + {"defaultUser": {"username": "admin", "password": "admin"}} + if values_key == "createUserJob" + else {} + ), "serviceAccount": { "annotations": { "iam.gke.io/gcp-service-account": "{{ .Release.Name }}-sa@project.iam", @@ -646,7 +655,13 @@ class TestJobAnnotationsTemplating: ("values", "show_only"), [ ( - {"createUserJob": {"annotations": {"job-ann": "{{ .Release.Name }}"}}}, + { + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "annotations": {"job-ann": "{{ .Release.Name }}"}, + } + }, "templates/jobs/create-user-job.yaml", ), ( diff --git a/chart/tests/helm_tests/airflow_aux/test_basic_helm_chart.py b/chart/tests/helm_tests/airflow_aux/test_basic_helm_chart.py index 6fe077fa182a1..627e92ce34f26 100644 --- a/chart/tests/helm_tests/airflow_aux/test_basic_helm_chart.py +++ b/chart/tests/helm_tests/airflow_aux/test_basic_helm_chart.py @@ -28,7 +28,6 @@ OBJECTS_STD_NAMING = { ("ServiceAccount", "test-basic-airflow-api-server"), - ("ServiceAccount", "test-basic-airflow-create-user-job"), ("ServiceAccount", "test-basic-airflow-dag-processor"), ("ServiceAccount", "test-basic-airflow-migrate-database-job"), ("ServiceAccount", "test-basic-airflow-redis"), @@ -64,7 +63,6 @@ ("StatefulSet", "test-basic-airflow-worker"), ("StatefulSet", "test-basic-airflow-triggerer"), ("StatefulSet", "test-basic-postgresql"), - ("Job", "test-basic-airflow-create-user"), ("Job", "test-basic-airflow-run-airflow-migrations"), } @@ -88,7 +86,6 @@ def test_basic_deployments(self): } expected = { ("ServiceAccount", "test-basic-api-server"), - ("ServiceAccount", "test-basic-create-user-job"), ("ServiceAccount", "test-basic-dag-processor"), ("ServiceAccount", "test-basic-migrate-database-job"), ("ServiceAccount", "test-basic-redis"), @@ -124,7 +121,6 @@ def test_basic_deployments(self): ("StatefulSet", "test-basic-postgresql"), ("StatefulSet", "test-basic-redis"), ("StatefulSet", "test-basic-worker"), - ("Job", "test-basic-create-user"), ("Job", "test-basic-run-airflow-migrations"), } assert list_of_kind_names_tuples == expected @@ -265,6 +261,7 @@ def test_labels_are_valid(self, executor, flower_routing_values, flower_routing_ "priorityClasses": [ {"name": "class1", "value": 10000}, ], + "createUserJob": {"enabled": True, "defaultUser": {"username": "admin", "password": "admin"}}, } values.update(flower_routing_values) @@ -395,6 +392,7 @@ def test_labels_are_valid_on_job_templates(self): "databaseCleanup": {"enabled": True}, "flower": {"enabled": True}, "postgresql": {"enabled": False}, # We won't check the objects created by the postgres chart + "createUserJob": {"enabled": True, "defaultUser": {"username": "admin", "password": "admin"}}, }, ) dict_of_labels_in_job_templates = { @@ -441,6 +439,7 @@ def test_annotations_on_airflow_pods_in_deployment(self): values={ "airflowPodAnnotations": {"test-annotation/safe-to-evict": "true"}, "flower": {"enabled": True}, + "createUserJob": {"enabled": True, "defaultUser": {"username": "admin", "password": "admin"}}, }, show_only=show_only, ) diff --git a/chart/tests/helm_tests/airflow_aux/test_create_user_job.py b/chart/tests/helm_tests/airflow_aux/test_create_user_job.py index 31c6729751850..a2c4b93fe9eda 100644 --- a/chart/tests/helm_tests/airflow_aux/test_create_user_job.py +++ b/chart/tests/helm_tests/airflow_aux/test_create_user_job.py @@ -18,7 +18,34 @@ import jmespath import pytest -from chart_utils.helm_template_generator import render_chart +from chart_utils.helm_template_generator import render_chart as _render_chart + + +def _deep_merge(base: dict, override: dict) -> dict: + """Merge ``override`` into ``base``, recursing into nested dicts.""" + merged = dict(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def render_chart(values=None, **kwargs): + """Render with the create-user job switched on and credentials supplied. + + The chart does not create a user by default and refuses to run the job without a + username and password, so tests covering the job have to opt in the same way a + deployment would. Values passed by a test win over these. + """ + opt_in = { + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + } + } + return _render_chart(values=_deep_merge(opt_in, values or {}), **kwargs) class TestCreateUserJob: diff --git a/chart/tests/helm_tests/airflow_aux/test_extra_env_env_from.py b/chart/tests/helm_tests/airflow_aux/test_extra_env_env_from.py index 7f9909da83abb..efcd83605c805 100644 --- a/chart/tests/helm_tests/airflow_aux/test_extra_env_env_from.py +++ b/chart/tests/helm_tests/airflow_aux/test_extra_env_env_from.py @@ -78,6 +78,11 @@ class TestExtraEnvEnvFrom: def setup_class(cls) -> None: values_str = textwrap.dedent( """ + createUserJob: + enabled: true + defaultUser: + username: admin + password: admin databaseCleanup: enabled: true applyCustomEnv: true diff --git a/chart/tests/helm_tests/security/test_rbac.py b/chart/tests/helm_tests/security/test_rbac.py index 46ca186338aa9..d9475eea10303 100644 --- a/chart/tests/helm_tests/security/test_rbac.py +++ b/chart/tests/helm_tests/security/test_rbac.py @@ -157,7 +157,11 @@ def test_deployments_no_rbac_no_sa(self): "triggerer": {"serviceAccount": {"create": False}}, "statsd": {"serviceAccount": {"create": False}}, "otelCollector": {"tracesEnabled": True, "serviceAccount": {"create": False}}, - "createUserJob": {"serviceAccount": {"create": False}}, + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "serviceAccount": {"create": False}, + }, "migrateDatabaseJob": {"serviceAccount": {"create": False}}, "flower": {"enabled": True, "serviceAccount": {"create": False}}, }, @@ -171,6 +175,10 @@ def test_deployments_no_rbac_with_sa(self): k8s_objects = render_chart( "test-rbac", values={ + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + }, "fullnameOverride": "test-rbac", "executor": "CeleryExecutor,KubernetesExecutor", "rbac": {"create": False}, @@ -223,7 +231,11 @@ def test_deployments_with_rbac_no_sa(self): "create": False, }, }, - "createUserJob": {"serviceAccount": {"create": False}}, + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "serviceAccount": {"create": False}, + }, "migrateDatabaseJob": {"serviceAccount": {"create": False}}, }, ) @@ -237,6 +249,10 @@ def test_deployments_with_rbac_with_sa(self): k8s_objects = render_chart( "test-rbac", values={ + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + }, "fullnameOverride": "test-rbac", "executor": "CeleryExecutor,KubernetesExecutor", "cleanup": {"enabled": True}, @@ -314,7 +330,11 @@ def test_service_account_custom_names(self): "name": CUSTOM_PGBOUNCER_NAME, }, }, - "createUserJob": {"serviceAccount": {"name": CUSTOM_CREATE_USER_JOBS_NAME}}, + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "serviceAccount": {"name": CUSTOM_CREATE_USER_JOBS_NAME}, + }, "migrateDatabaseJob": {"serviceAccount": {"name": CUSTOM_MIGRATE_DATABASE_JOBS_NAME}}, }, ) @@ -364,7 +384,11 @@ def test_service_account_custom_names_in_objects(self): "name": CUSTOM_PGBOUNCER_NAME, }, }, - "createUserJob": {"serviceAccount": {"name": CUSTOM_CREATE_USER_JOBS_NAME}}, + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "serviceAccount": {"name": CUSTOM_CREATE_USER_JOBS_NAME}, + }, "migrateDatabaseJob": {"serviceAccount": {"name": CUSTOM_MIGRATE_DATABASE_JOBS_NAME}}, }, ) @@ -409,6 +433,5 @@ def test_service_account_without_resource(self): "test-rbac-api-server", "test-rbac-triggerer", "test-rbac-migrate-database-job", - "test-rbac-create-user-job", ] assert sorted(list_of_sa_names) == sorted(service_account_names) diff --git a/chart/tests/helm_tests/security/test_scc_rolebinding.py b/chart/tests/helm_tests/security/test_scc_rolebinding.py index e89e7d1c23085..190e35ae5c175 100644 --- a/chart/tests/helm_tests/security/test_scc_rolebinding.py +++ b/chart/tests/helm_tests/security/test_scc_rolebinding.py @@ -401,7 +401,7 @@ def test_only_enable_components_role_binding_should_exists(self, executor): "redis": {"enabled": True}, "triggerer": {"enabled": True}, "migrateDatabaseJob": {"enabled": True}, - "createUserJob": {"enabled": True}, + "createUserJob": {"enabled": True, "defaultUser": {"username": "admin", "password": "admin"}}, "cleanup": {"enabled": True}, "databaseCleanup": {"enabled": True}, "dagProcessor": {"enabled": True}, diff --git a/chart/tests/helm_tests/security/test_security_context.py b/chart/tests/helm_tests/security/test_security_context.py index 3a1be1371e1f9..5ef596eadb2af 100644 --- a/chart/tests/helm_tests/security/test_security_context.py +++ b/chart/tests/helm_tests/security/test_security_context.py @@ -586,7 +586,13 @@ def test_disable_defaults_explicit_override_still_applied(self): "spec.template.spec.securityContext", ), ( - {"createUserJob": {"securityContexts": {"pod": {"runAsUser": 8000}}}}, + { + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + "securityContexts": {"pod": {"runAsUser": 8000}}, + } + }, "templates/jobs/create-user-job.yaml", "spec.template.spec.securityContext", ), diff --git a/chart/values.schema.json b/chart/values.schema.json index 88ebcee1fae3a..84c36a15c86dd 100644 --- a/chart/values.schema.json +++ b/chart/values.schema.json @@ -5061,7 +5061,7 @@ "enabled": { "description": "Whether the create user job should be created.", "type": "boolean", - "default": true + "default": false }, "defaultUser": { "description": "Optional default Airflow user information", @@ -5080,9 +5080,9 @@ "default": "Admin" }, "username": { - "description": "Default user username.", + "description": "Default user username. Must be set when the job is enabled.", "type": "string", - "default": "admin" + "default": "" }, "email": { "description": "Default user email address.", @@ -5100,9 +5100,9 @@ "default": "user" }, "password": { - "description": "Default user password.", + "description": "Default user password. Must be set when the job is enabled.", "type": "string", - "default": "admin" + "default": "" } } }, diff --git a/chart/values.yaml b/chart/values.yaml index 07e819fe3932e..c7662fbe6d26d 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -1427,17 +1427,22 @@ scheduler: # Airflow create user job settings createUserJob: - # Whether the create user job should be created - enabled: true + # Whether the create user job should be created. + # Disabled by default: an account created here exists on every install with the + # same credentials, so enable it only together with credentials of your own. + enabled: false - # Create initial user. + # Initial user to create. Only used when `createUserJob.enabled` is true, and both + # `username` and `password` must be supplied - the chart ships no defaults for them, + # so that enabling the job cannot create an account whose credentials are the same on + # every installation. defaultUser: role: Admin - username: admin + username: "" email: admin@example.com firstName: admin lastName: user - password: admin + password: "" # Limit the lifetime of the job object after it finished execution. ttlSecondsAfterFinished: 300 diff --git a/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py b/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py index 955b79b5e59cf..fe4d67433bc6f 100644 --- a/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py +++ b/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py @@ -1396,7 +1396,20 @@ def _deploy_helm_chart( ] if multi_namespace_mode: helm_command.extend(["--set", "multiNamespaceMode=true"]) - if not use_flask_appbuilder: + if use_flask_appbuilder: + # The chart creates no user of its own, so the account the tests log in as has to + # be asked for here, credentials included. + helm_command.extend( + [ + "--set", + "createUserJob.enabled=true", + "--set", + "createUserJob.defaultUser.username=admin", + "--set", + "createUserJob.defaultUser.password=admin", + ] + ) + else: helm_command.extend(["--set", "createUserJob.enabled=false"]) if upgrade: # force upgrade @@ -1748,6 +1761,12 @@ def dev( "scheduler": {"env": [{"name": "DEV_MODE", "value": "true"}]}, "triggerer": {"env": [{"name": "DEV_MODE", "value": "true"}]}, "dagProcessor": {"env": [{"name": "DEV_MODE", "value": "true"}]}, + # The chart creates no user of its own, so a throwaway one is asked for here to + # keep the dev-mode UI loggable-into. + "createUserJob": { + "enabled": True, + "defaultUser": {"username": "admin", "password": "admin"}, + }, } dev_env_values_path = Path(tmp_dir) / "dev-env-values.yaml" dev_env_values_path.write_text(yaml.safe_dump(dev_env_values, sort_keys=False))