From f74561dfa81f5a57bfcca07bf68b232d7f883315 Mon Sep 17 00:00:00 2001 From: vincbeck Date: Wed, 4 Sep 2024 11:51:19 -0400 Subject: [PATCH 1/6] Implement `SimpleAuthManager` --- airflow/auth/managers/simple/__init__.py | 17 ++ .../managers/simple/simple_auth_manager.py | 166 +++++++++++++ airflow/auth/managers/simple/user.py | 41 ++++ .../auth/managers/simple/views/__init__.py | 17 ++ airflow/auth/managers/simple/views/auth.py | 84 +++++++ .../default_webserver_config.py | 7 + airflow/www/static/js/login/Form.tsx | 44 ++++ airflow/www/static/js/login/index.test.tsx | 42 ++++ airflow/www/static/js/login/index.tsx | 73 ++++++ airflow/www/templates/airflow/login.html | 45 ++++ airflow/www/templates/appbuilder/navbar.html | 2 + airflow/www/webpack.config.js | 1 + tests/auth/managers/simple/__init__.py | 16 ++ .../simple/test_simple_auth_manager.py | 228 ++++++++++++++++++ tests/auth/managers/simple/test_user.py | 37 +++ tests/auth/managers/simple/views/__init__.py | 16 ++ tests/auth/managers/simple/views/test_auth.py | 78 ++++++ 17 files changed, 914 insertions(+) create mode 100644 airflow/auth/managers/simple/__init__.py create mode 100644 airflow/auth/managers/simple/simple_auth_manager.py create mode 100644 airflow/auth/managers/simple/user.py create mode 100644 airflow/auth/managers/simple/views/__init__.py create mode 100644 airflow/auth/managers/simple/views/auth.py create mode 100644 airflow/www/static/js/login/Form.tsx create mode 100644 airflow/www/static/js/login/index.test.tsx create mode 100644 airflow/www/static/js/login/index.tsx create mode 100644 airflow/www/templates/airflow/login.html create mode 100644 tests/auth/managers/simple/__init__.py create mode 100644 tests/auth/managers/simple/test_simple_auth_manager.py create mode 100644 tests/auth/managers/simple/test_user.py create mode 100644 tests/auth/managers/simple/views/__init__.py create mode 100644 tests/auth/managers/simple/views/test_auth.py diff --git a/airflow/auth/managers/simple/__init__.py b/airflow/auth/managers/simple/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/auth/managers/simple/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/auth/managers/simple/simple_auth_manager.py b/airflow/auth/managers/simple/simple_auth_manager.py new file mode 100644 index 0000000000000..9840ef7712a52 --- /dev/null +++ b/airflow/auth/managers/simple/simple_auth_manager.py @@ -0,0 +1,166 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +from flask import session, url_for + +from airflow.auth.managers.base_auth_manager import BaseAuthManager, ResourceMethod +from airflow.auth.managers.simple.views.auth import SimpleAuthManagerAuthenticationViews + +if TYPE_CHECKING: + from airflow.auth.managers.models.base_user import BaseUser + from airflow.auth.managers.models.resource_details import ( + AccessView, + ConfigurationDetails, + ConnectionDetails, + DagAccessEntity, + DagDetails, + DatasetDetails, + PoolDetails, + VariableDetails, + ) + from airflow.auth.managers.simple.user import SimpleAuthManagerUser + + +class SimpleAuthManagerRole(Enum): + """List of pre-defined roles in simple auth manager.""" + + # Admin role gives all permissions + ADMIN = "admin" + + # Viewer role gives all read-only permissions + VIEWER = "viewer" + + # User role gives viewer role permissions + access to DAGs + USER = "user" + + # OP role gives user role permissions + access to connections, config, pools, variables + OP = "op" + + +class SimpleAuthManager(BaseAuthManager): + """ + Simple auth manager. + + Default auth manager used in Airflow. This auth manager should not be used in production. + This auth manager is very basic and only intended for development and testing purposes. + + :param appbuilder: the flask app builder + """ + + def is_logged_in(self) -> bool: + return "user" in session + + def get_url_login(self, **kwargs) -> str: + return url_for("SimpleAuthManagerAuthenticationViews.login") + + def get_url_logout(self) -> str: + return url_for("SimpleAuthManagerAuthenticationViews.logout") + + def get_user(self) -> SimpleAuthManagerUser | None: + return session["user"] if self.is_logged_in() else None + + def is_authorized_configuration( + self, + *, + method: ResourceMethod, + details: ConfigurationDetails | None = None, + user: BaseUser | None = None, + ) -> bool: + return self._is_authorized(method=method, roles_to_allow=[SimpleAuthManagerRole.OP.value]) + + def is_authorized_connection( + self, + *, + method: ResourceMethod, + details: ConnectionDetails | None = None, + user: BaseUser | None = None, + ) -> bool: + return self._is_authorized(method=method, roles_to_allow=[SimpleAuthManagerRole.OP.value]) + + def is_authorized_dag( + self, + *, + method: ResourceMethod, + access_entity: DagAccessEntity | None = None, + details: DagDetails | None = None, + user: BaseUser | None = None, + ) -> bool: + return self._is_authorized( + method=method, + roles_to_allow=[SimpleAuthManagerRole.USER.value, SimpleAuthManagerRole.OP.value], + ) + + def is_authorized_dataset( + self, *, method: ResourceMethod, details: DatasetDetails | None = None, user: BaseUser | None = None + ) -> bool: + return self._is_authorized(method=method, roles_to_allow=[SimpleAuthManagerRole.OP.value]) + + def is_authorized_pool( + self, *, method: ResourceMethod, details: PoolDetails | None = None, user: BaseUser | None = None + ) -> bool: + return self._is_authorized(method=method, roles_to_allow=[SimpleAuthManagerRole.OP.value]) + + def is_authorized_variable( + self, *, method: ResourceMethod, details: VariableDetails | None = None, user: BaseUser | None = None + ) -> bool: + return self._is_authorized(method=method, roles_to_allow=[SimpleAuthManagerRole.OP.value]) + + def is_authorized_view(self, *, access_view: AccessView, user: BaseUser | None = None) -> bool: + return self._is_authorized(method="GET") + + def is_authorized_custom_view( + self, *, method: ResourceMethod | str, resource_name: str, user: BaseUser | None = None + ): + return self.is_logged_in() + + def register_views(self) -> None: + self.appbuilder.add_view_no_menu( + SimpleAuthManagerAuthenticationViews( + users=self.appbuilder.get_app.config.get("SIMPLE_AUTH_MANAGER_USERS", []) + ) + ) + + def _is_authorized( + self, + *, + method: ResourceMethod, + roles_to_allow: list[str] | None = None, + ): + """ + Return whether the user is authorized to access a given resource. + + :param method: the method to perform + :param roles_to_allow: list of roles giving access to the resource, if the user's role is one of these roles, they have access + """ + user = self.get_user() + if not user: + return False + role = user.get_role() + if role == SimpleAuthManagerRole.ADMIN.value: + return True + if method == "GET": + return True + + if not roles_to_allow: + roles_to_allow = [] + + return role in roles_to_allow diff --git a/airflow/auth/managers/simple/user.py b/airflow/auth/managers/simple/user.py new file mode 100644 index 0000000000000..fa032f596ee44 --- /dev/null +++ b/airflow/auth/managers/simple/user.py @@ -0,0 +1,41 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from airflow.auth.managers.models.base_user import BaseUser + + +class SimpleAuthManagerUser(BaseUser): + """ + User model for users managed by the simple auth manager. + + :param username: The username + :param role: The role associated to the user + """ + + def __init__(self, *, username: str, role: str) -> None: + self.username = username + self.role = role + + def get_id(self) -> str: + return self.username + + def get_name(self) -> str: + return self.username + + def get_role(self): + return self.role diff --git a/airflow/auth/managers/simple/views/__init__.py b/airflow/auth/managers/simple/views/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/auth/managers/simple/views/__init__.py @@ -0,0 +1,17 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/airflow/auth/managers/simple/views/auth.py b/airflow/auth/managers/simple/views/auth.py new file mode 100644 index 0000000000000..ca4e4f4ceb2c9 --- /dev/null +++ b/airflow/auth/managers/simple/views/auth.py @@ -0,0 +1,84 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import logging + +from flask import redirect, request, session, url_for +from flask_appbuilder import expose + +from airflow.auth.managers.simple.user import SimpleAuthManagerUser +from airflow.configuration import conf +from airflow.utils.state import State +from airflow.www.app import csrf +from airflow.www.views import AirflowBaseView + +logger = logging.getLogger(__name__) + + +class SimpleAuthManagerAuthenticationViews(AirflowBaseView): + """ + Views to authenticate using the simple auth manager. + + :param users: the list of users defined in the config + """ + + def __init__(self, users: list): + super().__init__() + self.users = users + + @expose("/login") + def login(self): + """Start login process.""" + state_color_mapping = State.state_color.copy() + state_color_mapping["no_status"] = state_color_mapping.pop(None) + standalone_dag_processor = conf.getboolean("scheduler", "standalone_dag_processor") + return self.render_template( + "airflow/login.html", + disable_nav_bar=True, + login_submit_url=url_for("SimpleAuthManagerAuthenticationViews.login_submit"), + auto_refresh_interval=conf.getint("webserver", "auto_refresh_interval"), + state_color_mapping=state_color_mapping, + standalone_dag_processor=standalone_dag_processor, + ) + + @expose("/logout", methods=["GET", "POST"]) + def logout(self): + """Start logout process.""" + session.clear() + return redirect(url_for("SimpleAuthManagerAuthenticationViews.login")) + + @csrf.exempt + @expose("/login_submit", methods=("GET", "POST")) + def login_submit(self): + """Redirect the user to this callback after login attempt.""" + username = request.form.get("username") + password = request.form.get("password") + + found_users = [ + user for user in self.users if user["username"] == username and user["password"] == password + ] + + if not username or not password or len(found_users) == 0: + return redirect(url_for("SimpleAuthManagerAuthenticationViews.login", error=["1"])) + + session["user"] = SimpleAuthManagerUser( + username=username, + role=found_users[0]["role"], + ) + + return redirect(url_for("Airflow.index")) diff --git a/airflow/config_templates/default_webserver_config.py b/airflow/config_templates/default_webserver_config.py index 3048bb21f4d59..1c9296b22bdbd 100644 --- a/airflow/config_templates/default_webserver_config.py +++ b/airflow/config_templates/default_webserver_config.py @@ -130,3 +130,10 @@ # APP_THEME = "superhero.css" # APP_THEME = "united.css" # APP_THEME = "yeti.css" + +# ---------------------------------------------------- +# Simple auth manager config +# ---------------------------------------------------- +# This list contains the list of users and their associated role in simple auth manager +# If the simple auth manager is used in your environment, this list controls who can access the environment +SIMPLE_AUTH_MANAGER_USERS = [] diff --git a/airflow/www/static/js/login/Form.tsx b/airflow/www/static/js/login/Form.tsx new file mode 100644 index 0000000000000..e8ca124f5a8b5 --- /dev/null +++ b/airflow/www/static/js/login/Form.tsx @@ -0,0 +1,44 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from "react"; +import { FormControl, FormLabel, Input, Stack, Button } from "@chakra-ui/react"; +import { getMetaValue } from "src/utils"; + +const LoginForm = () => ( +
+ + + Username + + + + + Password + + + + + +
+); + +export default LoginForm; diff --git a/airflow/www/static/js/login/index.test.tsx b/airflow/www/static/js/login/index.test.tsx new file mode 100644 index 0000000000000..9d8b0988db78b --- /dev/null +++ b/airflow/www/static/js/login/index.test.tsx @@ -0,0 +1,42 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* global describe */ + +import React from "react"; + +import { render } from "@testing-library/react"; + +import { Wrapper } from "src/utils/testUtils"; +import Login from "."; + +describe("Login page", () => { + test("Components renders properly", () => { + const { getAllByText } = render(, { + wrapper: Wrapper, + }); + + expect(getAllByText("Sign in")).toHaveLength(2); + expect(getAllByText("Enter your login and password below:")).toHaveLength( + 1 + ); + expect(getAllByText("Username")).toHaveLength(1); + expect(getAllByText("Password")).toHaveLength(1); + }); +}); diff --git a/airflow/www/static/js/login/index.tsx b/airflow/www/static/js/login/index.tsx new file mode 100644 index 0000000000000..2153eef354920 --- /dev/null +++ b/airflow/www/static/js/login/index.tsx @@ -0,0 +1,73 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* global document */ + +import React from "react"; +import { createRoot } from "react-dom/client"; +import createCache from "@emotion/cache"; +import { Alert, AlertIcon, Container, Heading, Text } from "@chakra-ui/react"; + +import App from "src/App"; +import LoginForm from "src/login/Form"; +import { useSearchParams } from "react-router-dom"; + +// create shadowRoot +const root = document.querySelector("#root"); +const shadowRoot = root?.attachShadow({ mode: "open" }); +const cache = createCache({ + container: shadowRoot, + key: "c", +}); +const mainElement = document.getElementById("react-container"); + +const Login = () => { + const [searchParams] = useSearchParams(); + const error = searchParams.get("error"); + + return ( + + + Sign in + + + {error && ( + + + Invalid credentials, please try again. + + )} + + Enter your login and password below: + + + ); +}; + +export default Login; + +if (mainElement) { + shadowRoot?.appendChild(mainElement); + const reactRoot = createRoot(mainElement); + reactRoot.render( + + + + ); +} diff --git a/airflow/www/templates/airflow/login.html b/airflow/www/templates/airflow/login.html new file mode 100644 index 0000000000000..5a25fb3b5f205 --- /dev/null +++ b/airflow/www/templates/airflow/login.html @@ -0,0 +1,45 @@ +{# + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + #} + +{% extends base_template %} + +{% block head_meta %} + {{ super() }} + +{% endblock %} + +{% block messages %} +{% endblock %} + +{% block content %} + {{ super() }} +
+
+
+{% endblock %} + +{% block tail_js %} + {{ super()}} + + +{% endblock %} diff --git a/airflow/www/templates/appbuilder/navbar.html b/airflow/www/templates/appbuilder/navbar.html index 141d778d2335f..149a517df24bc 100644 --- a/airflow/www/templates/appbuilder/navbar.html +++ b/airflow/www/templates/appbuilder/navbar.html @@ -47,7 +47,9 @@