This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new e012557cdfd AIP-84 | Add Auth for Variable (#47211)
e012557cdfd is described below
commit e012557cdfd7372651101053b21aaa14092d3391
Author: LIU ZHE YOU <[email protected]>
AuthorDate: Tue Mar 4 20:53:57 2025 +0800
AIP-84 | Add Auth for Variable (#47211)
* AIP-84 | Add Auth for Variable
* Fix requires_access_variable schema
---
.../api_fastapi/core_api/openapi/v1-generated.yaml | 12 ++++
.../core_api/routes/public/variables.py | 9 ++-
airflow/api_fastapi/core_api/security.py | 17 ++++++
.../core_api/routes/public/test_variables.py | 68 ++++++++++++++++++++++
4 files changed, 104 insertions(+), 2 deletions(-)
diff --git a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
index 259efb6cd49..e6e27e65b3f 100644
--- a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
+++ b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
@@ -6336,6 +6336,8 @@ paths:
summary: Delete Variable
description: Delete a variable entry.
operationId: delete_variable
+ security:
+ - OAuth2PasswordBearer: []
parameters:
- name: variable_key
in: path
@@ -6376,6 +6378,8 @@ paths:
summary: Get Variable
description: Get a variable entry.
operationId: get_variable
+ security:
+ - OAuth2PasswordBearer: []
parameters:
- name: variable_key
in: path
@@ -6420,6 +6424,8 @@ paths:
summary: Patch Variable
description: Update a variable by key.
operationId: patch_variable
+ security:
+ - OAuth2PasswordBearer: []
parameters:
- name: variable_key
in: path
@@ -6487,6 +6493,8 @@ paths:
summary: Get Variables
description: Get all Variables entries.
operationId: get_variables
+ security:
+ - OAuth2PasswordBearer: []
parameters:
- name: limit
in: query
@@ -6550,6 +6558,8 @@ paths:
summary: Post Variable
description: Create a variable.
operationId: post_variable
+ security:
+ - OAuth2PasswordBearer: []
requestBody:
required: true
content:
@@ -6593,6 +6603,8 @@ paths:
summary: Bulk Variables
description: Bulk create, update, and delete variables.
operationId: bulk_variables
+ security:
+ - OAuth2PasswordBearer: []
requestBody:
required: true
content:
diff --git a/airflow/api_fastapi/core_api/routes/public/variables.py
b/airflow/api_fastapi/core_api/routes/public/variables.py
index e8898ce6957..b1b9d232b85 100644
--- a/airflow/api_fastapi/core_api/routes/public/variables.py
+++ b/airflow/api_fastapi/core_api/routes/public/variables.py
@@ -38,6 +38,7 @@ from airflow.api_fastapi.core_api.datamodels.variables import
(
VariableResponse,
)
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
+from airflow.api_fastapi.core_api.security import requires_access_variable
from airflow.api_fastapi.core_api.services.public.variables import
BulkVariableService
from airflow.api_fastapi.logging.decorators import action_logging
from airflow.models.variable import Variable
@@ -49,6 +50,7 @@ variables_router = AirflowRouter(tags=["Variable"],
prefix="/variables")
"/{variable_key}",
status_code=status.HTTP_204_NO_CONTENT,
responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
+ dependencies=[Depends(requires_access_variable("DELETE"))],
)
def delete_variable(
variable_key: str,
@@ -64,6 +66,7 @@ def delete_variable(
@variables_router.get(
"/{variable_key}",
responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
+ dependencies=[Depends(requires_access_variable("GET"))],
)
def get_variable(
variable_key: str,
@@ -82,6 +85,7 @@ def get_variable(
@variables_router.get(
"",
+ dependencies=[Depends(requires_access_variable("GET"))],
)
def get_variables(
limit: QueryLimit,
@@ -124,6 +128,7 @@ def get_variables(
status.HTTP_404_NOT_FOUND,
]
),
+ dependencies=[Depends(requires_access_variable("PUT"))],
)
def patch_variable(
variable_key: str,
@@ -164,7 +169,7 @@ def patch_variable(
"",
status_code=status.HTTP_201_CREATED,
responses=create_openapi_http_exception_doc([status.HTTP_409_CONFLICT]),
- dependencies=[Depends(action_logging())],
+ dependencies=[Depends(action_logging()),
Depends(requires_access_variable("POST"))],
)
def post_variable(
post_body: VariableBody,
@@ -186,7 +191,7 @@ def post_variable(
return variable
-@variables_router.patch("")
+@variables_router.patch("",
dependencies=[Depends(requires_access_variable("PUT"))])
def bulk_variables(
request: BulkBody[VariableBody],
session: SessionDep,
diff --git a/airflow/api_fastapi/core_api/security.py
b/airflow/api_fastapi/core_api/security.py
index 2b267beaa8e..b1da04d6791 100644
--- a/airflow/api_fastapi/core_api/security.py
+++ b/airflow/api_fastapi/core_api/security.py
@@ -30,6 +30,7 @@ from airflow.auth.managers.models.resource_details import (
DagAccessEntity,
DagDetails,
PoolDetails,
+ VariableDetails,
)
from airflow.configuration import conf
from airflow.utils.jwt_signer import JWTSigner, get_signing_key
@@ -130,6 +131,22 @@ def requires_access_connection(method: ResourceMethod) ->
Callable:
return inner
+def requires_access_variable(method: ResourceMethod) -> Callable[[Request,
BaseUser | None], None]:
+ def inner(
+ request: Request,
+ user: Annotated[BaseUser | None, Depends(get_user)] = None,
+ ) -> None:
+ variable_key: str | None = request.path_params.get("variable_key")
+
+ _requires_access(
+ is_authorized_callback=lambda:
get_auth_manager().is_authorized_variable(
+ method=method, details=VariableDetails(key=variable_key),
user=user
+ ),
+ )
+
+ return inner
+
+
def _requires_access(
*,
is_authorized_callback: Callable[[], bool],
diff --git a/tests/api_fastapi/core_api/routes/public/test_variables.py
b/tests/api_fastapi/core_api/routes/public/test_variables.py
index c5af61fc035..b9837f4d253 100644
--- a/tests/api_fastapi/core_api/routes/public/test_variables.py
+++ b/tests/api_fastapi/core_api/routes/public/test_variables.py
@@ -107,6 +107,14 @@ class TestDeleteVariable(TestVariableEndpoint):
variables = session.query(Variable).all()
assert len(variables) == 3
+ def test_delete_should_respond_401(self, unauthenticated_test_client):
+ response =
unauthenticated_test_client.delete(f"/public/variables/{TEST_VARIABLE_KEY}")
+ assert response.status_code == 401
+
+ def test_delete_should_respond_403(self, unauthorized_test_client):
+ response =
unauthorized_test_client.delete(f"/public/variables/{TEST_VARIABLE_KEY}")
+ assert response.status_code == 403
+
def test_delete_should_respond_404(self, test_client):
response = test_client.delete(f"/public/variables/{TEST_VARIABLE_KEY}")
assert response.status_code == 404
@@ -163,6 +171,14 @@ class TestGetVariable(TestVariableEndpoint):
assert response.status_code == 200
assert response.json() == expected_response
+ def test_get_should_respond_401(self, unauthenticated_test_client):
+ response =
unauthenticated_test_client.get(f"/public/variables/{TEST_VARIABLE_KEY}")
+ assert response.status_code == 401
+
+ def test_get_should_respond_403(self, unauthorized_test_client):
+ response =
unauthorized_test_client.get(f"/public/variables/{TEST_VARIABLE_KEY}")
+ assert response.status_code == 403
+
def test_get_should_respond_404(self, test_client):
response = test_client.get(f"/public/variables/{TEST_VARIABLE_KEY}")
assert response.status_code == 404
@@ -220,6 +236,14 @@ class TestGetVariables(TestVariableEndpoint):
assert body["total_entries"] == expected_total_entries
assert [variable["key"] for variable in body["variables"]] ==
expected_keys
+ def test_get_should_respond_401(self, unauthenticated_test_client):
+ response = unauthenticated_test_client.get("/public/variables")
+ assert response.status_code == 401
+
+ def test_get_should_respond_403(self, unauthorized_test_client):
+ response = unauthorized_test_client.get("/public/variables")
+ assert response.status_code == 403
+
class TestPatchVariable(TestVariableEndpoint):
@pytest.mark.enable_redact
@@ -303,6 +327,20 @@ class TestPatchVariable(TestVariableEndpoint):
body = response.json()
assert body["detail"] == "Invalid body, key from request body doesn't
match uri parameter"
+ def test_patch_should_respond_401(self, unauthenticated_test_client):
+ response = unauthenticated_test_client.patch(
+ f"/public/variables/{TEST_VARIABLE_KEY}",
+ json={"key": TEST_VARIABLE_KEY, "value": "some_value",
"description": None},
+ )
+ assert response.status_code == 401
+
+ def test_patch_should_respond_403(self, unauthorized_test_client):
+ response = unauthorized_test_client.patch(
+ f"/public/variables/{TEST_VARIABLE_KEY}",
+ json={"key": TEST_VARIABLE_KEY, "value": "some_value",
"description": None},
+ )
+ assert response.status_code == 403
+
def test_patch_should_respond_404(self, test_client):
response = test_client.patch(
f"/public/variables/{TEST_VARIABLE_KEY}",
@@ -378,6 +416,28 @@ class TestPostVariable(TestVariableEndpoint):
assert response.status_code == 201
assert response.json() == expected_response
+ def test_post_should_respond_401(self, unauthenticated_test_client):
+ response = unauthenticated_test_client.post(
+ "/public/variables",
+ json={
+ "key": "new variable key",
+ "value": "new variable value",
+ "description": "new variable description",
+ },
+ )
+ assert response.status_code == 401
+
+ def test_post_should_respond_403(self, unauthorized_test_client):
+ response = unauthorized_test_client.post(
+ "/public/variables",
+ json={
+ "key": "new variable key",
+ "value": "new variable value",
+ "description": "new variable description",
+ },
+ )
+ assert response.status_code == 403
+
def test_post_should_respond_409_when_key_exists(self, test_client,
session):
self.create_variables()
# Attempting to post a variable with an existing key
@@ -927,3 +987,11 @@ class TestBulkVariables(TestVariableEndpoint):
response_data = response.json()
for key, value in expected_results.items():
assert response_data[key] == value
+
+ def test_bulk_variables_should_respond_401(self,
unauthenticated_test_client):
+ response = unauthenticated_test_client.patch("/public/variables",
json={})
+ assert response.status_code == 401
+
+ def test_bulk_variables_should_respond_403(self, unauthorized_test_client):
+ response = unauthorized_test_client.patch("/public/variables", json={})
+ assert response.status_code == 403