From 6f2e83d48534b7e4dc960af2def01eb51af95e4b Mon Sep 17 00:00:00 2001 From: Md Zeeshan Alam Date: Sat, 18 Apr 2026 05:26:43 +0530 Subject: [PATCH 1/5] Allow null values for Variable value field to handle decryption failures gracefully --- .../src/airflow/api_fastapi/core_api/datamodels/variables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py index 001d8c70e95ad..58f55ec0cd9bd 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py @@ -33,7 +33,7 @@ class VariableResponse(BaseModel): """Variable serializer for responses.""" key: str - val: str = Field(alias="value") + val: str | None = Field(alias="value", default=None) description: str | None is_encrypted: bool team_name: str | None From e0aed772fe6c88ac1a236098acebc5dd517782c3 Mon Sep 17 00:00:00 2001 From: Md Zeeshan Alam Date: Mon, 20 Apr 2026 23:59:47 +0530 Subject: [PATCH 2/5] Document rationale in code, add a regression test, and add newsfragment 65452.bugfix.rst --- airflow-core/newsfragments/65452.bugfix.rst | 5 ++++ .../core_api/datamodels/variables.py | 8 ++++++ .../core_api/routes/public/test_variables.py | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 airflow-core/newsfragments/65452.bugfix.rst diff --git a/airflow-core/newsfragments/65452.bugfix.rst b/airflow-core/newsfragments/65452.bugfix.rst new file mode 100644 index 0000000000000..3b1564b124196 --- /dev/null +++ b/airflow-core/newsfragments/65452.bugfix.rst @@ -0,0 +1,5 @@ +``GET /variables/{variable_key}`` now returns ``"value": null`` when the stored +value cannot be decrypted (for example after a Fernet key rotation or +misconfiguration), matching the behavior of the list endpoint +``GET /variables``. Previously the detail endpoint failed with HTTP 500 because +the response schema declared ``value`` as a required string. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py index 58f55ec0cd9bd..a801620497692 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py @@ -33,6 +33,12 @@ class VariableResponse(BaseModel): """Variable serializer for responses.""" key: str + # ``val`` is intentionally Optional. ``Variable.get_val`` returns ``None`` when + # the stored value cannot be decrypted (e.g. after a Fernet key rotation or + # misconfiguration). Declaring the field as nullable surfaces that state as + # ``"value": null`` in the response instead of raising HTTP 500 due to + # response-schema validation. This also matches the behavior of the list + # endpoint which has always returned ``null`` in the same scenario. val: str | None = Field(alias="value", default=None) description: str | None is_encrypted: bool @@ -40,6 +46,8 @@ class VariableResponse(BaseModel): @model_validator(mode="after") def redact_val(self) -> Self: + # Skip redaction when decryption failed upstream; there is nothing to mask + # and ``None`` must be preserved so clients can detect the failure. if self.val is None: return self try: diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py index 0e37495392cbb..c6e224c9fe45d 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py @@ -254,6 +254,31 @@ def test_get_should_respond_404(self, test_client): body = response.json() assert f"The Variable with key: `{TEST_VARIABLE_KEY}` was not found" == body["detail"] + def test_get_should_respond_200_with_null_value_when_decryption_fails(self, test_client, session): + """ + Regression test for https://github.com/apache/airflow/pull/65452. + + If the stored value cannot be decrypted (for example after a Fernet key + rotation) ``Variable.get_val`` returns ``None``. The endpoint must then + respond with HTTP 200 and ``"value": null`` instead of failing with an + HTTP 500 caused by response-schema validation. + """ + self.create_variables() + with mock.patch( + "airflow.models.variable.Variable.get_val", return_value=None + ): + response = test_client.get(f"/variables/{TEST_VARIABLE_KEY}") + + assert response.status_code == 200 + body = response.json() + assert body == { + "key": TEST_VARIABLE_KEY, + "value": None, + "description": TEST_VARIABLE_DESCRIPTION, + "is_encrypted": True, + "team_name": None, + } + class TestGetVariables(TestVariableEndpoint): @pytest.mark.enable_redact From 39936842a2157f9bc993c26afaa6b9df528b05f3 Mon Sep 17 00:00:00 2001 From: Md Zeeshan Alam Date: Thu, 30 Apr 2026 22:21:12 +0530 Subject: [PATCH 3/5] Fix CI for #65452: single-line newsfragment, real fernet decrypt mock, regenerate openapi spec + UI/airflowctl datamodels --- airflow-core/newsfragments/65452.bugfix.rst | 6 +----- .../core_api/openapi/v2-rest-api-generated.yaml | 5 +++-- .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 11 +++++++++-- .../src/airflow/ui/openapi-gen/requests/types.gen.ts | 2 +- .../core_api/routes/public/test_variables.py | 7 ++++--- .../src/airflowctl/api/datamodels/generated.py | 2 +- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/airflow-core/newsfragments/65452.bugfix.rst b/airflow-core/newsfragments/65452.bugfix.rst index 3b1564b124196..c263e025ce609 100644 --- a/airflow-core/newsfragments/65452.bugfix.rst +++ b/airflow-core/newsfragments/65452.bugfix.rst @@ -1,5 +1 @@ -``GET /variables/{variable_key}`` now returns ``"value": null`` when the stored -value cannot be decrypted (for example after a Fernet key rotation or -misconfiguration), matching the behavior of the list endpoint -``GET /variables``. Previously the detail endpoint failed with HTTP 500 because -the response schema declared ``value`` as a required string. +Return ``"value": null`` (HTTP 200) from ``GET /variables/{key}`` when the stored value cannot be decrypted, matching ``GET /variables`` instead of failing with HTTP 500. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml index a56c9adf821fa..aa401a49158e7 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml @@ -15074,7 +15074,9 @@ components: type: string title: Key value: - type: string + anyOf: + - type: string + - type: 'null' title: Value description: anyOf: @@ -15092,7 +15094,6 @@ components: type: object required: - key - - value - description - is_encrypted - team_name diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index bc4c7179c2dbb..06ace35db448a 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -7073,7 +7073,14 @@ export const $VariableResponse = { title: 'Key' }, value: { - type: 'string', + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], title: 'Value' }, description: { @@ -7104,7 +7111,7 @@ export const $VariableResponse = { } }, type: 'object', - required: ['key', 'value', 'description', 'is_encrypted', 'team_name'], + required: ['key', 'description', 'is_encrypted', 'team_name'], title: 'VariableResponse', description: 'Variable serializer for responses.' } as const; diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index 5b216da03a58b..b9a2605577cec 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -1720,7 +1720,7 @@ export type VariableCollectionResponse = { */ export type VariableResponse = { key: string; - value: string; + value?: string | null; description: string | null; is_encrypted: boolean; team_name: string | null; diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py index c6e224c9fe45d..b31f41865149b 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py @@ -263,10 +263,11 @@ def test_get_should_respond_200_with_null_value_when_decryption_fails(self, test respond with HTTP 200 and ``"value": null`` instead of failing with an HTTP 500 caused by response-schema validation. """ + from cryptography.fernet import InvalidToken + self.create_variables() - with mock.patch( - "airflow.models.variable.Variable.get_val", return_value=None - ): + with mock.patch("airflow.models.variable.get_fernet") as mock_get_fernet: + mock_get_fernet.return_value.decrypt.side_effect = InvalidToken response = test_client.get(f"/variables/{TEST_VARIABLE_KEY}") assert response.status_code == 200 diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/src/airflowctl/api/datamodels/generated.py index 276c8699de058..b523023b926b1 100644 --- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py +++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py @@ -994,7 +994,7 @@ class VariableResponse(BaseModel): """ key: Annotated[str, Field(title="Key")] - value: Annotated[str, Field(title="Value")] + value: Annotated[str | None, Field(title="Value")] = None description: Annotated[str | None, Field(title="Description")] = None is_encrypted: Annotated[bool, Field(title="Is Encrypted")] team_name: Annotated[str | None, Field(title="Team Name")] = None From bc5ff80a605ffa26fe4928e71debe260e884bb5e Mon Sep 17 00:00:00 2001 From: Md Zeeshan Alam Date: Fri, 1 May 2026 16:41:54 +0530 Subject: [PATCH 4/5] Fix CI for #65452: handle nullable Variable.value in UI, regenerate openapi/airflowctl datamodels, single-line newsfragment, real fernet decrypt mock --- .../src/pages/Variables/ManageVariable/EditVariableButton.tsx | 2 +- airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx b/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx index 65d65014c359c..264da41bccc13 100644 --- a/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Variables/ManageVariable/EditVariableButton.tsx @@ -50,7 +50,7 @@ const EditVariableButton = ({ disabled, variable }: Props) => { description: variable.description ?? "", key: variable.key, team_name: variable.team_name ?? "", - value: formatValue(variable.value), + value: formatValue(variable.value ?? ""), }; const { editVariable, error, isPending, setError } = useEditVariable(initialVariableValue, { onSuccessConfirm: onClose, diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx index bae62e719d03d..bcc323978bb86 100644 --- a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx @@ -94,9 +94,9 @@ const getColumns = ({ cell: ({ row }) => ( ), From 40d78239b088266b75c19b8f68db22a89423b6e0 Mon Sep 17 00:00:00 2001 From: Md Zeeshan Alam Date: Sat, 30 May 2026 16:34:59 +0530 Subject: [PATCH 5/5] Removed all the comments, suggested by the reviewers --- airflow-core/newsfragments/65452.bugfix.rst | 1 - .../airflow/api_fastapi/core_api/datamodels/variables.py | 8 -------- 2 files changed, 9 deletions(-) delete mode 100644 airflow-core/newsfragments/65452.bugfix.rst diff --git a/airflow-core/newsfragments/65452.bugfix.rst b/airflow-core/newsfragments/65452.bugfix.rst deleted file mode 100644 index c263e025ce609..0000000000000 --- a/airflow-core/newsfragments/65452.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Return ``"value": null`` (HTTP 200) from ``GET /variables/{key}`` when the stored value cannot be decrypted, matching ``GET /variables`` instead of failing with HTTP 500. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py index a801620497692..58f55ec0cd9bd 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py @@ -33,12 +33,6 @@ class VariableResponse(BaseModel): """Variable serializer for responses.""" key: str - # ``val`` is intentionally Optional. ``Variable.get_val`` returns ``None`` when - # the stored value cannot be decrypted (e.g. after a Fernet key rotation or - # misconfiguration). Declaring the field as nullable surfaces that state as - # ``"value": null`` in the response instead of raising HTTP 500 due to - # response-schema validation. This also matches the behavior of the list - # endpoint which has always returned ``null`` in the same scenario. val: str | None = Field(alias="value", default=None) description: str | None is_encrypted: bool @@ -46,8 +40,6 @@ class VariableResponse(BaseModel): @model_validator(mode="after") def redact_val(self) -> Self: - # Skip redaction when decryption failed upstream; there is nothing to mask - # and ``None`` must be preserved so clients can detect the failure. if self.val is None: return self try: