diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f29799a..4102453b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### Fixed - Do not use immutable Secret objects for internal secrets. Migrate existing secrets to mutable versions ([#770]). +- Allow OPA integration with arbitrary user role when using Airflow 3. Previously, this only worked for users with Admin role ([#800]) [#750]: https://github.com/stackabletech/airflow-operator/pull/750 [#765]: https://github.com/stackabletech/airflow-operator/pull/765 @@ -39,6 +40,7 @@ [#784]: https://github.com/stackabletech/airflow-operator/pull/784 [#786]: https://github.com/stackabletech/airflow-operator/pull/786 [#795]: https://github.com/stackabletech/airflow-operator/pull/795 +[#800]: https://github.com/stackabletech/airflow-operator/pull/800 ## [26.3.0] - 2026-03-16 diff --git a/tests/templates/kuttl/opa-interop/10-patch-ns.yaml.j2 b/tests/templates/kuttl/opa-interop/10-patch-ns.yaml.j2 new file mode 100644 index 00000000..67185acf --- /dev/null +++ b/tests/templates/kuttl/opa-interop/10-patch-ns.yaml.j2 @@ -0,0 +1,9 @@ +{% if test_scenario['values']['openshift'] == 'true' %} +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl patch namespace $NAMESPACE -p '{"metadata":{"labels":{"pod-security.kubernetes.io/enforce":"privileged"}}}' + timeout: 120 +{% endif %} diff --git a/tests/templates/kuttl/opa-interop/11-assert.yaml b/tests/templates/kuttl/opa-interop/11-assert.yaml new file mode 100644 index 00000000..319e927a --- /dev/null +++ b/tests/templates/kuttl/opa-interop/11-assert.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: test-airflow-postgresql +timeout: 480 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: airflow-postgresql +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/opa-interop/11-install-postgresql.yaml b/tests/templates/kuttl/opa-interop/11-install-postgresql.yaml new file mode 100644 index 00000000..0c333b31 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/11-install-postgresql.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: >- + helm install airflow-postgresql + --namespace $NAMESPACE + --version 12.5.6 + --values 11_helm-bitnami-postgresql-values.yaml + --repo https://charts.bitnami.com/bitnami postgresql + --wait + timeout: 600 diff --git a/tests/templates/kuttl/opa-interop/11_helm-bitnami-postgresql-values.yaml.j2 b/tests/templates/kuttl/opa-interop/11_helm-bitnami-postgresql-values.yaml.j2 new file mode 100644 index 00000000..b7040a7a --- /dev/null +++ b/tests/templates/kuttl/opa-interop/11_helm-bitnami-postgresql-values.yaml.j2 @@ -0,0 +1,44 @@ +--- +global: + security: + allowInsecureImages: true + +image: + repository: bitnamilegacy/postgresql + +volumePermissions: + enabled: false + image: + repository: bitnamilegacy/os-shell + securityContext: + runAsUser: auto + +metrics: + image: + repository: bitnamilegacy/postgres-exporter + +primary: + podSecurityContext: +{% if test_scenario['values']['openshift'] == 'true' %} + enabled: false +{% else %} + enabled: true +{% endif %} + containerSecurityContext: + enabled: false + resources: + requests: + memory: "128Mi" + cpu: "512m" + limits: + memory: "128Mi" + cpu: "1" + +shmVolume: + chmod: + enabled: false + +auth: + username: airflow + password: airflow + database: airflow diff --git a/tests/templates/kuttl/opa-interop/20-assert.yaml b/tests/templates/kuttl/opa-interop/20-assert.yaml new file mode 100644 index 00000000..f55ef436 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/20-assert.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +commands: + - script: kubectl -n $NAMESPACE wait --for=condition=available --timeout=10m opacluster/test-opa diff --git a/tests/templates/kuttl/opa-interop/20-install-opa.yaml.j2 b/tests/templates/kuttl/opa-interop/20-install-opa.yaml.j2 new file mode 100644 index 00000000..bbc3d53b --- /dev/null +++ b/tests/templates/kuttl/opa-interop/20-install-opa.yaml.j2 @@ -0,0 +1,34 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: install-opa +--- +apiVersion: opa.stackable.tech/v1alpha1 +kind: OpaCluster +metadata: + name: test-opa +spec: + image: +{% if test_scenario['values']['opa-latest'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opa-latest'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['opa-latest'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['opa-latest'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + servers: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + containers: + opa: + loggers: + decision: + level: INFO + roleGroups: + default: {} diff --git a/tests/templates/kuttl/opa-interop/30-assert.yaml b/tests/templates/kuttl/opa-interop/30-assert.yaml new file mode 100644 index 00000000..ad3c8974 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/30-assert.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: install-airflow +timeout: 1200 +commands: + - script: > + kubectl --namespace $NAMESPACE + wait --for=condition=available=true + airflowclusters.airflow.stackable.tech/airflow + --timeout 301s +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: airflow-webserver-default +status: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: airflow-scheduler-default +status: + readyReplicas: 1 + replicas: 1 diff --git a/tests/templates/kuttl/opa-interop/30-install-airflow.yaml.j2 b/tests/templates/kuttl/opa-interop/30-install-airflow.yaml.j2 new file mode 100644 index 00000000..d7fca396 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/30-install-airflow.yaml.j2 @@ -0,0 +1,64 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: install-airflow +--- +apiVersion: v1 +kind: Secret +metadata: + name: airflow-admin-credentials +type: Opaque +stringData: + adminUser.username: airflow + adminUser.firstname: Airflow + adminUser.lastname: Admin + adminUser.email: airflow@airflow.com + adminUser.password: airflow +--- +apiVersion: v1 +kind: Secret +metadata: + name: airflow-postgresql-credentials +stringData: + username: airflow + password: airflow +--- +apiVersion: airflow.stackable.tech/v1alpha1 +kind: AirflowCluster +metadata: + name: airflow +spec: + image: +{% if test_scenario['values']['airflow-latest'].find(",") > 0 %} + custom: "{{ test_scenario['values']['airflow-latest'].split(',')[1] }}" + productVersion: "{{ test_scenario['values']['airflow-latest'].split(',')[0] }}" +{% else %} + productVersion: "{{ test_scenario['values']['airflow-latest'] }}" +{% endif %} + pullPolicy: IfNotPresent + clusterConfig: + authorization: + opa: + configMapName: test-opa + package: airflow + credentialsSecretName: airflow-admin-credentials + metadataDatabase: + postgresql: + host: airflow-postgresql + database: airflow + credentialsSecretName: airflow-postgresql-credentials + loadExamples: true + webservers: + roleConfig: + listenerClass: external-unstable + envOverrides: + AIRFLOW__CORE__AUTH_OPA_CACHE_MAXSIZE: "0" + roleGroups: + default: + replicas: 1 + kubernetesExecutors: {} + schedulers: + roleGroups: + default: + replicas: 1 diff --git a/tests/templates/kuttl/opa-interop/31-opa-rules.yaml b/tests/templates/kuttl/opa-interop/31-opa-rules.yaml new file mode 100644 index 00000000..f609b738 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/31-opa-rules.yaml @@ -0,0 +1,56 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: airflow-rules + labels: + opa.stackable.tech/bundle: "true" +data: + airflow.rego: | + package airflow + + # Default deny everything. Only the rules below should permit anything. + default is_authorized_configuration := false + default is_authorized_connection := false + default is_authorized_dag := false + default is_authorized_pool := false + default is_authorized_variable := false + default is_authorized_view := false + default is_authorized_custom_view := false + default is_authorized_backfill := false + default is_authorized_asset := false + default is_authorized_asset_alias := false + + # Allow-all for the "airflow" admin so the operator can bootstrap the + # cluster (creating the admin user, syncing example DAGs, etc). + is_authorized_configuration if input.user.name == "airflow" + is_authorized_connection if input.user.name == "airflow" + is_authorized_dag if input.user.name == "airflow" + is_authorized_pool if input.user.name == "airflow" + is_authorized_variable if input.user.name == "airflow" + is_authorized_view if input.user.name == "airflow" + is_authorized_custom_view if input.user.name == "airflow" + is_authorized_backfill if input.user.name == "airflow" + is_authorized_asset if input.user.name == "airflow" + is_authorized_asset_alias if input.user.name == "airflow" + + # jane.doe is OPA-allow-all - same shape as the airflow admin block above. + # Intentionally broad: the goal of this test is to isolate the FAB + # filter/listing layer. Any 403 we see for jane.doe must therefore come + # from FAB (the layer with the bug), not from OPA denying some auxiliary + # check (is_authorized_view, is_authorized_configuration, ...) that the + # listing endpoint also performs. + # + # With this rule + Admin role: both layers say yes -> test passes. + # With this rule + Public role: OPA says yes everywhere, FAB's + # unoverridden get_authorized_dag_ids says no -> 403 -> test fails (bug). + is_authorized_configuration if input.user.name == "jane.doe" + is_authorized_connection if input.user.name == "jane.doe" + is_authorized_dag if input.user.name == "jane.doe" + is_authorized_pool if input.user.name == "jane.doe" + is_authorized_variable if input.user.name == "jane.doe" + is_authorized_view if input.user.name == "jane.doe" + is_authorized_custom_view if input.user.name == "jane.doe" + is_authorized_backfill if input.user.name == "jane.doe" + is_authorized_asset if input.user.name == "jane.doe" + is_authorized_asset_alias if input.user.name == "jane.doe" diff --git a/tests/templates/kuttl/opa-interop/32-create-users.yaml b/tests/templates/kuttl/opa-interop/32-create-users.yaml new file mode 100644 index 00000000..e1da1be7 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/32-create-users.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +metadata: + name: create-users +timeout: 300 +commands: + - script: | + kubectl exec -n $NAMESPACE airflow-webserver-default-0 -- airflow users create \ + --username "jane.doe" \ + --firstname "Jane" \ + --lastname "Doe" \ + --email "jane.doe@stackable.tech" \ + --password "T8mn72D9" \ + --role "Public" + kubectl exec -n $NAMESPACE airflow-webserver-default-0 -- airflow users create \ + --username "john.doe" \ + --firstname "John" \ + --lastname "Doe" \ + --email "john.doe@stackable.tech" \ + --password "T8mn72D9" \ + --role "Admin" diff --git a/tests/templates/kuttl/opa-interop/40-assert.yaml b/tests/templates/kuttl/opa-interop/40-assert.yaml new file mode 100644 index 00000000..e0b70674 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/40-assert.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +commands: + - script: kubectl exec -n $NAMESPACE -i airflow-webserver-default-0 -- python3 < 40_check-listing_3.py diff --git a/tests/templates/kuttl/opa-interop/40_check-listing_3.py b/tests/templates/kuttl/opa-interop/40_check-listing_3.py new file mode 100644 index 00000000..937f91c9 --- /dev/null +++ b/tests/templates/kuttl/opa-interop/40_check-listing_3.py @@ -0,0 +1,170 @@ +""" +Regression test for OPA-based DAG authorization on Airflow 3. + +The OPA rules in 31-opa-rules.yaml grant jane.doe access to everything and +grant john.doe nothing. john.doe is also FAB Admin, so if OPA is not +consulted he would inherit full access from FAB. Both the direct-resource +endpoint and the listing endpoint must therefore go through OPA: + + - jane.doe: GET /api/v2/dags/example_trigger_target_dag -> 200 + - jane.doe: GET /api/v2/dags -> 200, contains DAG + - john.doe: both endpoints deny / return empty + +The assertions retry for a short window to absorb OPA bundle-puller +latency (Stackable OPA reloads ConfigMap-backed rules on an interval, +not instantly). +""" + +import logging +import sys +import time + +import requests + +logging.basicConfig( + level="INFO", format="%(levelname)s: %(message)s", stream=sys.stdout +) +log = logging.getLogger(__name__) + +URL = "http://localhost:8080" +API = f"{URL}/api/v2" + +USER = {"username": "jane.doe", "password": "T8mn72D9"} +OTHER_USER = {"username": "john.doe", "password": "T8mn72D9"} +DAG_ID = "example_trigger_target_dag" + +RETRY_TIMEOUT_SECONDS = 60 +RETRY_INTERVAL_SECONDS = 3 + + +def obtain_access_token(user): + response = requests.post( + f"{URL}/auth/token", + headers={"Content-Type": "application/json"}, + json={"username": user["username"], "password": user["password"]}, + ) + if response.status_code not in (200, 201): + log.error( + "Failed to obtain access token: %s - %s", + response.status_code, + response.text, + ) + sys.exit(1) + log.info("Got access token for %s", user["username"]) + return response.json()["access_token"] + + +def retry(label, check): + """Run `check()` until it returns None (= success) or timeout. + + `check` returns None on pass and an AssertionError instance on fail. + On timeout the most recent failure is raised. + """ + deadline = time.monotonic() + RETRY_TIMEOUT_SECONDS + last_failure = None + attempt = 0 + while True: + attempt += 1 + result = check() + if result is None: + log.info("%s: ok (attempt %d)", label, attempt) + return + last_failure = result + if time.monotonic() >= deadline: + log.info("%s: giving up after %d attempts", label, attempt) + raise last_failure + log.info( + "%s: not ready yet (attempt %d), retrying in %ds", + label, + attempt, + RETRY_INTERVAL_SECONDS, + ) + time.sleep(RETRY_INTERVAL_SECONDS) + + +def assert_other_user_has_no_access(): + """Negative control: john.doe is FAB Admin but has no OPA rules. + + If OPA is consulted, both endpoints default-deny. If OPA were bypassed + and FAB took over, Admin would see everything - which would make + jane.doe's assertions meaningless. + """ + token = obtain_access_token(OTHER_USER) + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + direct = requests.get(f"{API}/dags/{DAG_ID}", headers=headers) + log.info( + "negative control: GET /api/v2/dags/{id} for %s -> %s", + OTHER_USER["username"], + direct.status_code, + ) + if direct.status_code == 200: + raise AssertionError( + f"OPA enforcement broken: GET /api/v2/dags/{DAG_ID} returned 200 " + f"for {OTHER_USER['username']} (Admin, no OPA rules). OPA should " + f"have default-denied this request." + ) + + listing = requests.get(f"{API}/dags", params={"limit": 1000}, headers=headers) + log.info( + "negative control: GET /api/v2/dags for %s -> %s", + OTHER_USER["username"], + listing.status_code, + ) + if listing.status_code == 200: + dag_ids = [d["dag_id"] for d in listing.json().get("dags", [])] + if dag_ids: + raise AssertionError( + f"OPA enforcement broken: GET /api/v2/dags returned {dag_ids} " + f"for {OTHER_USER['username']} (Admin, no OPA rules). OPA " + f"should have filtered to empty or denied outright." + ) + + +def main(): + assert_other_user_has_no_access() + + token = obtain_access_token(USER) + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + def check_direct(): + r = requests.get(f"{API}/dags/{DAG_ID}", headers=headers) + if r.status_code == 200: + return None + return AssertionError( + f"GET /api/v2/dags/{DAG_ID} returned {r.status_code} for " + f"{USER['username']}, even though OPA allows it.\n" + f"Response: {r.text}" + ) + + def check_listing(): + # dag_id_pattern + a wide limit so we don't depend on Airflow's + # default page size (50 in Airflow 3 - example_trigger_target_dag + # alphabetically falls past the first page in a default install). + r = requests.get( + f"{API}/dags", + params={"dag_id_pattern": DAG_ID, "limit": 1000}, + headers=headers, + ) + if r.status_code != 200: + return AssertionError( + f"GET /api/v2/dags returned {r.status_code} for " + f"{USER['username']}, even though OPA allows it.\n" + f"Response: {r.text}" + ) + dag_ids = sorted(d["dag_id"] for d in r.json().get("dags", [])) + if DAG_ID not in dag_ids: + return AssertionError( + f"GET /api/v2/dags returned {dag_ids} for " + f"{USER['username']}. OPA allows {DAG_ID} but the listing " + f"path filtered it out." + ) + return None + + retry("GET /api/v2/dags/{id}", check_direct) + retry("GET /api/v2/dags", check_listing) + + log.info("All assertions passed.") + + +main() diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index dbb659c0..f1ec22d0 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -68,6 +68,11 @@ tests: - airflow - opa-latest - openshift + - name: opa-interop + dimensions: + - airflow-latest + - opa-latest + - openshift - name: resources dimensions: - airflow-latest