Skip to content

Commit 1c901f4

Browse files
committed
fix: rename / change final check to TestAssert
1 parent 10cddd7 commit 1c901f4

2 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
apiVersion: kuttl.dev/v1beta1
3+
kind: TestAssert
4+
timeout: 120
5+
commands:
6+
- script: kubectl exec -n $NAMESPACE -i airflow-webserver-default-0 -- python3 < 40_check-listing_3.py
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Regression test for OPA-based DAG authorization on Airflow 3.
3+
4+
The OPA rules in 31-opa-rules.yaml grant jane.doe access to everything and
5+
grant john.doe nothing. john.doe is also FAB Admin, so if OPA is not
6+
consulted he would inherit full access from FAB. Both the direct-resource
7+
endpoint and the listing endpoint must therefore go through OPA:
8+
9+
- jane.doe: GET /api/v2/dags/example_trigger_target_dag -> 200
10+
- jane.doe: GET /api/v2/dags -> 200, contains DAG
11+
- john.doe: both endpoints deny / return empty
12+
13+
The assertions retry for a short window to absorb OPA bundle-puller
14+
latency (Stackable OPA reloads ConfigMap-backed rules on an interval,
15+
not instantly).
16+
"""
17+
18+
import logging
19+
import sys
20+
import time
21+
22+
import requests
23+
24+
logging.basicConfig(
25+
level="INFO", format="%(levelname)s: %(message)s", stream=sys.stdout
26+
)
27+
log = logging.getLogger(__name__)
28+
29+
URL = "http://localhost:8080"
30+
API = f"{URL}/api/v2"
31+
32+
USER = {"username": "jane.doe", "password": "T8mn72D9"}
33+
OTHER_USER = {"username": "john.doe", "password": "T8mn72D9"}
34+
DAG_ID = "example_trigger_target_dag"
35+
36+
RETRY_TIMEOUT_SECONDS = 60
37+
RETRY_INTERVAL_SECONDS = 3
38+
39+
40+
def obtain_access_token(user):
41+
response = requests.post(
42+
f"{URL}/auth/token",
43+
headers={"Content-Type": "application/json"},
44+
json={"username": user["username"], "password": user["password"]},
45+
)
46+
if response.status_code not in (200, 201):
47+
log.error(
48+
"Failed to obtain access token: %s - %s",
49+
response.status_code,
50+
response.text,
51+
)
52+
sys.exit(1)
53+
log.info("Got access token for %s", user["username"])
54+
return response.json()["access_token"]
55+
56+
57+
def retry(label, check):
58+
"""Run `check()` until it returns None (= success) or timeout.
59+
60+
`check` returns None on pass and an AssertionError instance on fail.
61+
On timeout the most recent failure is raised.
62+
"""
63+
deadline = time.monotonic() + RETRY_TIMEOUT_SECONDS
64+
last_failure = None
65+
attempt = 0
66+
while True:
67+
attempt += 1
68+
result = check()
69+
if result is None:
70+
log.info("%s: ok (attempt %d)", label, attempt)
71+
return
72+
last_failure = result
73+
if time.monotonic() >= deadline:
74+
log.info("%s: giving up after %d attempts", label, attempt)
75+
raise last_failure
76+
log.info(
77+
"%s: not ready yet (attempt %d), retrying in %ds",
78+
label,
79+
attempt,
80+
RETRY_INTERVAL_SECONDS,
81+
)
82+
time.sleep(RETRY_INTERVAL_SECONDS)
83+
84+
85+
def assert_other_user_has_no_access():
86+
"""Negative control: john.doe is FAB Admin but has no OPA rules.
87+
88+
If OPA is consulted, both endpoints default-deny. If OPA were bypassed
89+
and FAB took over, Admin would see everything - which would make
90+
jane.doe's assertions meaningless.
91+
"""
92+
token = obtain_access_token(OTHER_USER)
93+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
94+
95+
direct = requests.get(f"{API}/dags/{DAG_ID}", headers=headers)
96+
log.info(
97+
"negative control: GET /api/v2/dags/{id} for %s -> %s",
98+
OTHER_USER["username"],
99+
direct.status_code,
100+
)
101+
if direct.status_code == 200:
102+
raise AssertionError(
103+
f"OPA enforcement broken: GET /api/v2/dags/{DAG_ID} returned 200 "
104+
f"for {OTHER_USER['username']} (Admin, no OPA rules). OPA should "
105+
f"have default-denied this request."
106+
)
107+
108+
listing = requests.get(f"{API}/dags", params={"limit": 1000}, headers=headers)
109+
log.info(
110+
"negative control: GET /api/v2/dags for %s -> %s",
111+
OTHER_USER["username"],
112+
listing.status_code,
113+
)
114+
if listing.status_code == 200:
115+
dag_ids = [d["dag_id"] for d in listing.json().get("dags", [])]
116+
if dag_ids:
117+
raise AssertionError(
118+
f"OPA enforcement broken: GET /api/v2/dags returned {dag_ids} "
119+
f"for {OTHER_USER['username']} (Admin, no OPA rules). OPA "
120+
f"should have filtered to empty or denied outright."
121+
)
122+
123+
124+
def main():
125+
assert_other_user_has_no_access()
126+
127+
token = obtain_access_token(USER)
128+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
129+
130+
def check_direct():
131+
r = requests.get(f"{API}/dags/{DAG_ID}", headers=headers)
132+
if r.status_code == 200:
133+
return None
134+
return AssertionError(
135+
f"GET /api/v2/dags/{DAG_ID} returned {r.status_code} for "
136+
f"{USER['username']}, even though OPA allows it.\n"
137+
f"Response: {r.text}"
138+
)
139+
140+
def check_listing():
141+
# dag_id_pattern + a wide limit so we don't depend on Airflow's
142+
# default page size (50 in Airflow 3 - example_trigger_target_dag
143+
# alphabetically falls past the first page in a default install).
144+
r = requests.get(
145+
f"{API}/dags",
146+
params={"dag_id_pattern": DAG_ID, "limit": 1000},
147+
headers=headers,
148+
)
149+
if r.status_code != 200:
150+
return AssertionError(
151+
f"GET /api/v2/dags returned {r.status_code} for "
152+
f"{USER['username']}, even though OPA allows it.\n"
153+
f"Response: {r.text}"
154+
)
155+
dag_ids = sorted(d["dag_id"] for d in r.json().get("dags", []))
156+
if DAG_ID not in dag_ids:
157+
return AssertionError(
158+
f"GET /api/v2/dags returned {dag_ids} for "
159+
f"{USER['username']}. OPA allows {DAG_ID} but the listing "
160+
f"path filtered it out."
161+
)
162+
return None
163+
164+
retry("GET /api/v2/dags/{id}", check_direct)
165+
retry("GET /api/v2/dags", check_listing)
166+
167+
log.info("All assertions passed.")
168+
169+
170+
main()

0 commit comments

Comments
 (0)