Skip to content

Commit 95212ec

Browse files
razvanadwk67maltesander
authored
tests: enable Airflow 3 integration tests for OPA (#642)
* wip: working tests (except auth: opa, oidc) * wip: changing python test files * single health.py for all airflow versions * single metrics.py for all airflow versions * update tests with new commons scripts * tests: use "airflow-latest" instead of "airflow" * cleanup and code comments * restore deleted env * use correct webserver service * restore operator to release list * test: fix oidc * wip: get logging tests to work post-merge * make env-vars version-specific * fixed resolution of webserver url for execution api * test: update opa test * relaxed default resources * test: opa almost working * update test defs for oidc/opa * changelog * successful run of opa tests with airflow 3 * split opa tests for 2 and 3 in separate files * Update tests/templates/kuttl/logging/51-assert.yaml.j2 Co-authored-by: Malte Sander <[email protected]> * code review changes * code review changes * move containerdebug cmd to a function * replaced random key with hard-coded one with comment * cleanup and better comment * for 3.x: only scheduler updates FAB permissions and restrict workers * update Airlow 3 test dimension --------- Co-authored-by: Andrew Kenworthy <[email protected]> Co-authored-by: Andrew Kenworthy <[email protected]> Co-authored-by: Malte Sander <[email protected]>
1 parent 24395c2 commit 95212ec

File tree

7 files changed

+287
-24
lines changed

7 files changed

+287
-24
lines changed

tests/templates/kuttl/opa/30-install-airflow.yaml.j2

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ metadata:
2424
name: airflow
2525
spec:
2626
image:
27-
{% if test_scenario['values']['airflow-non-experimental'].find(",") > 0 %}
28-
custom: "{{ test_scenario['values']['airflow-non-experimental'].split(',')[1] }}"
29-
productVersion: "{{ test_scenario['values']['airflow-non-experimental'].split(',')[0] }}"
27+
{% if test_scenario['values']['airflow'].find(",") > 0 %}
28+
custom: "{{ test_scenario['values']['airflow'].split(',')[1] }}"
29+
productVersion: "{{ test_scenario['values']['airflow'].split(',')[0] }}"
3030
{% else %}
31-
productVersion: "{{ test_scenario['values']['airflow-non-experimental'] }}"
31+
productVersion: "{{ test_scenario['values']['airflow'] }}"
3232
{% endif %}
3333
pullPolicy: IfNotPresent
3434
clusterConfig:
@@ -53,6 +53,7 @@ spec:
5353
configOverrides:
5454
webserver_config.py:
5555
WTF_CSRF_ENABLED: "False" # Allow "POST /login/" without CSRF token
56+
AUTH_OPA_CACHE_MAXSIZE_DEFAULT: "0" # disable decision caching for easy debugging
5657
roleGroups:
5758
default:
5859
replicas: 1

tests/templates/kuttl/opa/31-opa-rules.yaml

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ data:
1212
default is_authorized_configuration := false
1313
default is_authorized_connection := false
1414
default is_authorized_dag := false
15+
# This is no longer present in Airflow 3
1516
default is_authorized_dataset := false
1617
default is_authorized_pool := false
1718
default is_authorized_variable := false
1819
default is_authorized_view := false
1920
default is_authorized_custom_view := false
21+
# These are new in Airflow 3
22+
default is_authorized_backfill := false
23+
default is_authorized_asset := false
24+
default is_authorized_asset_alias := false
2025
2126
# Allow the user "airflow" to create test users
2227
# POST /auth/fab/v1/users
@@ -26,6 +31,42 @@ data:
2631
2732
input.user.name == "airflow"
2833
}
34+
is_authorized_configuration if {
35+
input.user.name == "airflow"
36+
}
37+
is_authorized_configuration if {
38+
input.user.name == "airflow"
39+
}
40+
is_authorized_connection if {
41+
input.user.name == "airflow"
42+
}
43+
is_authorized_dag if {
44+
input.user.name == "airflow"
45+
}
46+
is_authorized_dataset if {
47+
input.user.name == "airflow"
48+
}
49+
is_authorized_pool if {
50+
input.user.name == "airflow"
51+
}
52+
is_authorized_variable if {
53+
input.user.name == "airflow"
54+
}
55+
is_authorized_view if {
56+
input.user.name == "airflow"
57+
}
58+
is_authorized_custom_view if {
59+
input.user.name == "airflow"
60+
}
61+
is_authorized_backfill if {
62+
input.user.name == "airflow"
63+
}
64+
is_authorized_asset if {
65+
input.user.name == "airflow"
66+
}
67+
is_authorized_asset_alias if {
68+
input.user.name == "airflow"
69+
}
2970
3071
# GET /api/v1/config
3172
is_authorized_configuration if {
@@ -72,7 +113,9 @@ data:
72113
is_authorized_dag if {
73114
input.method == "GET"
74115
input.access_entity == "RUN"
75-
input.details.id == null
116+
# Airflow 2 sets this to null
117+
# Ignore for now so this rule can be used with Airflow 2 and 3
118+
# input.details.id == "~"
76119
77120
input.user.name == "jane.doe"
78121
}
@@ -148,3 +191,24 @@ data:
148191
149192
input.user.name == "jane.doe"
150193
}
194+
195+
# GET /api/v2/backfills
196+
is_authorized_backfill if {
197+
input.method == "GET"
198+
199+
input.user.name == "jane.doe"
200+
}
201+
202+
# GET /api/v2/assets
203+
is_authorized_asset if {
204+
input.method == "GET"
205+
206+
input.user.name == "jane.doe"
207+
}
208+
209+
# GET /api/v2/assets/aliases
210+
is_authorized_asset_alias if {
211+
input.method == "GET"
212+
213+
input.user.name == "jane.doe"
214+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
apiVersion: kuttl.dev/v1beta1
3+
kind: TestStep
4+
metadata:
5+
name: create-users
6+
timeout: 300
7+
commands:
8+
- script: |
9+
kubectl exec -n $NAMESPACE airflow-webserver-default-0 -- airflow users create \
10+
--username "jane.doe" \
11+
--firstname "Jane" \
12+
--lastname "Doe" \
13+
--email "[email protected]" \
14+
--password "T8mn72D9" \
15+
--role "User"
16+
17+
kubectl exec -n $NAMESPACE airflow-webserver-default-0 -- airflow users create \
18+
--username "richard.roe" \
19+
--firstname "Richard" \
20+
--lastname "Roe" \
21+
--email "[email protected]" \
22+
--password "NvfpU518" \
23+
--role "User"

tests/templates/kuttl/opa/41-check-authorization.yaml renamed to tests/templates/kuttl/opa/41-check-authorization.yaml.j2

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,9 @@ metadata:
66
commands:
77
- script: >
88
kubectl cp
9-
41_check-authorization.py
9+
{% if test_scenario['values']['airflow'].startswith("2") %}
10+
41_check-authorization_2.py
11+
{% else %}
12+
41_check-authorization_3.py
13+
{% endif %}
1014
$NAMESPACE/test-runner-0:/stackable/check-authorization.py

tests/templates/kuttl/opa/41_check-authorization.py renamed to tests/templates/kuttl/opa/41_check-authorization_2.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,6 @@
2929
url = "http://airflow-webserver-default:8080"
3030

3131

32-
def create_user(user):
33-
requests.post(
34-
f"{url}/auth/fab/v1/users",
35-
auth=("airflow", "airflow"),
36-
json=user,
37-
)
38-
39-
4032
def check_api_authorization_for_user(
4133
user, expected_status_code, method, endpoint, data=None, api="api/v1"
4234
):
@@ -152,10 +144,6 @@ def test_is_authorized_custom_view():
152144
)
153145

154146

155-
# Create test users
156-
create_user(user_jane_doe)
157-
create_user(user_richard_roe)
158-
159147
test_is_authorized_configuration()
160148
test_is_authorized_connection()
161149
test_is_authorized_dag()
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import logging
2+
import sys
3+
4+
import requests
5+
6+
logging.basicConfig(
7+
level="DEBUG", format="%(asctime)s %(levelname)s: %(message)s", stream=sys.stdout
8+
)
9+
10+
log = logging.getLogger(__name__)
11+
12+
# user to headers mapping
13+
headers: dict[str, dict[str, str]] = {}
14+
15+
# Jane Doe has access to specific resources.
16+
user_jane_doe = {
17+
"first_name": "Jane",
18+
"last_name": "Doe",
19+
"username": "jane.doe",
20+
"email": "[email protected]",
21+
"roles": [{"name": "User"}],
22+
"password": "T8mn72D9",
23+
}
24+
# Richard Roe has no access.
25+
user_richard_roe = {
26+
"first_name": "Richard",
27+
"last_name": "Roe",
28+
"username": "richard.roe",
29+
"email": "[email protected]",
30+
"roles": [{"name": "User"}],
31+
"password": "NvfpU518",
32+
}
33+
34+
url = "http://airflow-webserver-default:8080"
35+
api = "api/v2"
36+
url_login = f"{url}/auth/login"
37+
38+
39+
def obtain_access_token(user: dict[str, str]) -> str:
40+
token_url = f"{url}/auth/token"
41+
42+
data = {"username": user["username"], "password": user["password"]}
43+
44+
headers = {"Content-Type": "application/json"}
45+
46+
response = requests.post(token_url, headers=headers, json=data)
47+
48+
if response.status_code == 200 or response.status_code == 201:
49+
token_data = response.json()
50+
access_token = token_data["access_token"]
51+
log.info(f"Got access token: {access_token}")
52+
return access_token
53+
else:
54+
log.error(
55+
f"Failed to obtain access token: {response.status_code} - {response.text}"
56+
)
57+
sys.exit(1)
58+
59+
60+
def assert_status_code(msg, left, right):
61+
if left != right:
62+
raise AssertionError(f"{msg}\n\tleft: {left}\n\tright: {right}")
63+
64+
65+
def check_api_authorization_for_user(
66+
user, expected_status_code, method, endpoint, data=None
67+
):
68+
api_url = f"{url}/{api}"
69+
70+
response = requests.request(
71+
method, f"{api_url}/{endpoint}", headers=headers[user["email"]], json=data
72+
)
73+
74+
assert_status_code(
75+
f"Unexpected status code for {user["email"]=}",
76+
response.status_code,
77+
expected_status_code,
78+
)
79+
80+
81+
def check_api_authorization(method, endpoint, expected_status_code=200, data=None):
82+
check_api_authorization_for_user(
83+
user_jane_doe, expected_status_code, method=method, endpoint=endpoint, data=data
84+
)
85+
check_api_authorization_for_user(
86+
user_richard_roe, 403, method=method, endpoint=endpoint, data=data
87+
)
88+
89+
90+
def check_website_authorization_for_user(user, expected_status_code):
91+
username = user["username"]
92+
password = user["password"]
93+
with requests.Session() as session:
94+
login_response = session.post(
95+
url_login,
96+
data=f"username={username}&password={password}",
97+
allow_redirects=True,
98+
headers={"Content-Type": "application/x-www-form-urlencoded"},
99+
)
100+
assert login_response.ok, f"Login for {username} failed"
101+
home_response = session.get(f"{url}/home", allow_redirects=True)
102+
assert_status_code(
103+
f"GET /home for user [{username}] failed",
104+
home_response.status_code,
105+
expected_status_code,
106+
)
107+
108+
109+
def test_is_authorized_configuration():
110+
# section == null
111+
check_api_authorization("GET", "config")
112+
# section != null
113+
check_api_authorization("GET", "config/section/core/option/dags_folder")
114+
115+
116+
def test_is_authorized_connection():
117+
# conn_id == null
118+
check_api_authorization("GET", "connections")
119+
120+
121+
def test_is_authorized_dag():
122+
# access_entity == null and id == null
123+
# There is no API endpoint to test this case.
124+
125+
# access_entity == null and id != null
126+
check_api_authorization("GET", "dags/example_trigger_target_dag")
127+
128+
# access_entity != null and id == null
129+
# Check "GET /dags/~/dagRuns" because access to "GET /dags" is always allowed
130+
check_api_authorization("GET", "dags/~/dagRuns")
131+
132+
# access_entity != null and id != null
133+
check_api_authorization("GET", "dags/example_trigger_target_dag/dagRuns")
134+
135+
136+
def test_is_authorized_dataset():
137+
# uri == null
138+
check_api_authorization("GET", "datasets")
139+
# uri != null
140+
check_api_authorization("GET", "datasets/s3%3A%2F%2Fdag1%2Foutput_1.txt")
141+
142+
143+
def test_is_authorized_pool():
144+
# name == null
145+
check_api_authorization("GET", "pools")
146+
# name != null
147+
check_api_authorization("GET", "pools/default_pool")
148+
149+
150+
def test_is_authorized_variable():
151+
# key != null
152+
check_api_authorization(
153+
"POST", "variables", 201, data={"key": "myVar", "value": "1"}
154+
)
155+
# key == null
156+
check_api_authorization("GET", "variables/myVar")
157+
158+
159+
def test_is_authorized_asset():
160+
# name == null
161+
check_api_authorization("GET", "assets")
162+
# name != null
163+
check_api_authorization("GET", "assets/3") ## 'test-asset' has id 3
164+
165+
166+
def test_is_authorized_view():
167+
check_website_authorization_for_user(user_jane_doe, 200)
168+
check_website_authorization_for_user(user_richard_roe, 200)
169+
170+
171+
access_token_jane_doe = obtain_access_token(user_jane_doe)
172+
headers[user_jane_doe["email"]] = {
173+
"Authorization": f"Bearer {access_token_jane_doe}",
174+
"Content-Type": "application/json",
175+
}
176+
access_token_richard_roe = obtain_access_token(user_richard_roe)
177+
headers[user_richard_roe["email"]] = {
178+
"Authorization": f"Bearer {access_token_richard_roe}",
179+
"Content-Type": "application/json",
180+
}
181+
182+
test_is_authorized_configuration()
183+
test_is_authorized_connection()
184+
test_is_authorized_dag()
185+
test_is_authorized_pool()
186+
test_is_authorized_variable()
187+
test_is_authorized_view()
188+
test_is_authorized_asset()

tests/test-definition.yaml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@ dimensions:
1818
- 3.0.1
1919
# To use a custom image, add a comma and the full name after the product version
2020
# - 2.9.3,oci.stackable.tech/sandbox/airflow:2.9.3-stackable0.0.0-dev
21-
- name: airflow-non-experimental
22-
values:
23-
- 2.10.5
24-
# To use a custom image, add a comma and the full name after the product version
25-
# - 2.9.3,oci.stackable.tech/sandbox/airflow:2.9.3-stackable0.0.0-dev
2621
- name: opa-latest
2722
values:
2823
- 1.4.2
@@ -66,7 +61,7 @@ tests:
6661
- openshift
6762
- name: opa
6863
dimensions:
69-
- airflow-non-experimental
64+
- airflow
7065
- opa-latest
7166
- openshift
7267
- name: resources

0 commit comments

Comments
 (0)