This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 b599d81121 Implement `SimpleAuthManager` (#42004)
b599d81121 is described below
commit b599d81121f3b9103658fba9c33d6f3bebe33cf6
Author: Vincent <[email protected]>
AuthorDate: Tue Sep 17 09:24:25 2024 -0700
Implement `SimpleAuthManager` (#42004)
---
airflow/auth/managers/simple/__init__.py | 17 ++
.../auth/managers/simple/simple_auth_manager.py | 238 ++++++++++++++++++
airflow/auth/managers/simple/user.py | 41 +++
airflow/auth/managers/simple/views/__init__.py | 17 ++
airflow/auth/managers/simple/views/auth.py | 88 +++++++
.../config_templates/default_webserver_config.py | 17 ++
airflow/www/app.py | 4 -
airflow/www/extensions/init_auth_manager.py | 1 +
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 ++
.../managers/simple/test_simple_auth_manager.py | 274 +++++++++++++++++++++
tests/auth/managers/simple/test_user.py | 37 +++
tests/auth/managers/simple/views/__init__.py | 16 ++
tests/auth/managers/simple/views/test_auth.py | 76 ++++++
19 files changed, 1045 insertions(+), 4 deletions(-)
diff --git a/airflow/auth/managers/simple/__init__.py
b/airflow/auth/managers/simple/__init__.py
new file mode 100644
index 0000000000..217e5db960
--- /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 0000000000..1d73341719
--- /dev/null
+++ b/airflow/auth/managers/simple/simple_auth_manager.py
@@ -0,0 +1,238 @@
+#
+# 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 json
+import os
+import random
+from collections import namedtuple
+from enum import Enum
+from typing import TYPE_CHECKING
+
+from flask import session, url_for
+from termcolor import colored
+
+from airflow.auth.managers.base_auth_manager import BaseAuthManager,
ResourceMethod
+from airflow.auth.managers.simple.views.auth import
SimpleAuthManagerAuthenticationViews
+from hatch_build import AIRFLOW_ROOT_PATH
+
+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(namedtuple("SimpleAuthManagerRole", "name order"),
Enum):
+ """
+ List of pre-defined roles in simple auth manager.
+
+ The first attribute defines the name that references this role in the
config.
+ The second attribute defines the order between roles. The role with order
X means it grants access to
+ resources under its umbrella and all resources under the umbrella of roles
of lower order
+ """
+
+ # VIEWER role gives all read-only permissions
+ VIEWER = "VIEWER", 0
+
+ # USER role gives viewer role permissions + access to DAGs
+ USER = "USER", 1
+
+ # OP role gives user role permissions + access to connections, config,
pools, variables
+ OP = "OP", 2
+
+ # ADMIN role gives all permissions
+ ADMIN = "ADMIN", 3
+
+
+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
+ """
+
+ # File that contains the generated passwords
+ GENERATED_PASSWORDS_FILE = (
+ AIRFLOW_ROOT_PATH / "generated" /
"simple_auth_manager_passwords.json.generated"
+ )
+
+ # Cache containing the password associated to a username
+ passwords: dict[str, str] = {}
+
+ def init(self) -> None:
+ user_passwords_from_file = {}
+
+ # Read passwords from file
+ if os.path.isfile(self.GENERATED_PASSWORDS_FILE):
+ with open(self.GENERATED_PASSWORDS_FILE) as file:
+ passwords_str = file.read().strip()
+ user_passwords_from_file = json.loads(passwords_str)
+
+ users =
self.appbuilder.get_app.config.get("SIMPLE_AUTH_MANAGER_USERS", [])
+ usernames = {user["username"] for user in users}
+ self.passwords = {
+ username: password
+ for username, password in user_passwords_from_file.items()
+ if username in usernames
+ }
+ for user in users:
+ if user["username"] not in self.passwords:
+ # User dot not exist in the file, adding it
+ self.passwords[user["username"]] = self._generate_password()
+
+ self._print_output(f"Password for user '{user['username']}':
{self.passwords[user['username']]}")
+
+ with open(self.GENERATED_PASSWORDS_FILE, "w") as file:
+ file.write(json.dumps(self.passwords))
+
+ 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,
allow_role=SimpleAuthManagerRole.OP)
+
+ def is_authorized_connection(
+ self,
+ *,
+ method: ResourceMethod,
+ details: ConnectionDetails | None = None,
+ user: BaseUser | None = None,
+ ) -> bool:
+ return self._is_authorized(method=method,
allow_role=SimpleAuthManagerRole.OP)
+
+ 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,
+ allow_get_role=SimpleAuthManagerRole.VIEWER,
+ allow_role=SimpleAuthManagerRole.USER,
+ )
+
+ def is_authorized_dataset(
+ self, *, method: ResourceMethod, details: DatasetDetails | None =
None, user: BaseUser | None = None
+ ) -> bool:
+ return self._is_authorized(
+ method=method,
+ allow_get_role=SimpleAuthManagerRole.VIEWER,
+ allow_role=SimpleAuthManagerRole.OP,
+ )
+
+ def is_authorized_pool(
+ self, *, method: ResourceMethod, details: PoolDetails | None = None,
user: BaseUser | None = None
+ ) -> bool:
+ return self._is_authorized(
+ method=method,
+ allow_get_role=SimpleAuthManagerRole.VIEWER,
+ allow_role=SimpleAuthManagerRole.OP,
+ )
+
+ def is_authorized_variable(
+ self, *, method: ResourceMethod, details: VariableDetails | None =
None, user: BaseUser | None = None
+ ) -> bool:
+ return self._is_authorized(method=method,
allow_role=SimpleAuthManagerRole.OP)
+
+ def is_authorized_view(self, *, access_view: AccessView, user: BaseUser |
None = None) -> bool:
+ return self._is_authorized(method="GET",
allow_role=SimpleAuthManagerRole.VIEWER)
+
+ def is_authorized_custom_view(
+ self, *, method: ResourceMethod | str, resource_name: str, user:
BaseUser | None = None
+ ):
+ return self._is_authorized(method="GET",
allow_role=SimpleAuthManagerRole.VIEWER)
+
+ def register_views(self) -> None:
+ self.appbuilder.add_view_no_menu(
+ SimpleAuthManagerAuthenticationViews(
+
users=self.appbuilder.get_app.config.get("SIMPLE_AUTH_MANAGER_USERS", []),
+ passwords=self.passwords,
+ )
+ )
+
+ def _is_authorized(
+ self,
+ *,
+ method: ResourceMethod,
+ allow_role: SimpleAuthManagerRole,
+ allow_get_role: SimpleAuthManagerRole | None = None,
+ ):
+ """
+ Return whether the user is authorized to access a given resource.
+
+ :param method: the method to perform
+ :param allow_role: minimal role giving access to the resource, if the
user's role is greater or
+ equal than this role, they have access
+ :param allow_get_role: minimal role giving access to the resource, if
the user's role is greater or
+ equal than this role, they have access. If not provided,
``allow_role`` is used
+ """
+ user = self.get_user()
+ if not user:
+ return False
+ role_str = user.get_role().upper()
+ role = SimpleAuthManagerRole[role_str]
+ if role == SimpleAuthManagerRole.ADMIN:
+ return True
+
+ if not allow_get_role:
+ allow_get_role = allow_role
+
+ if method == "GET":
+ return role.order >= allow_get_role.order
+ return role.order >= allow_role.order
+
+ @staticmethod
+ def _generate_password() -> str:
+ return
"".join(random.choices("abcdefghkmnpqrstuvwxyzABCDEFGHKMNPQRSTUVWXYZ23456789",
k=16))
+
+ @staticmethod
+ def _print_output(output: str):
+ name = "Simple auth manager"
+ colorized_name = colored(f"{name:10}", "white")
+ for line in output.splitlines():
+ print(f"{colorized_name} | {line.strip()}")
diff --git a/airflow/auth/managers/simple/user.py
b/airflow/auth/managers/simple/user.py
new file mode 100644
index 0000000000..fa032f596e
--- /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 0000000000..217e5db960
--- /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 0000000000..8ab02d0a01
--- /dev/null
+++ b/airflow/auth/managers/simple/views/auth.py
@@ -0,0 +1,88 @@
+# 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
+ :param passwords: dict associating a username to its password
+ """
+
+ def __init__(self, users: list, passwords: dict[str, str]):
+ super().__init__()
+ self.users = users
+ self.passwords = passwords
+
+ @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
self.passwords[user["username"]] == 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 3048bb21f4..71bdf9e99d 100644
--- a/airflow/config_templates/default_webserver_config.py
+++ b/airflow/config_templates/default_webserver_config.py
@@ -130,3 +130,20 @@ AUTH_TYPE = AUTH_DB
# 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.
+# Example:
+# [{
+# "username": "admin",
+# "role": "admin",
+# }]
+SIMPLE_AUTH_MANAGER_USERS = [
+ {
+ "username": "admin",
+ "role": "admin",
+ }
+]
diff --git a/airflow/www/app.py b/airflow/www/app.py
index 270ffe5719..f5e1191fb4 100644
--- a/airflow/www/app.py
+++ b/airflow/www/app.py
@@ -36,7 +36,6 @@ from airflow.settings import _ENABLE_AIP_44
from airflow.utils.json import AirflowJsonProvider
from airflow.www.extensions.init_appbuilder import init_appbuilder
from airflow.www.extensions.init_appbuilder_links import init_appbuilder_links
-from airflow.www.extensions.init_auth_manager import get_auth_manager
from airflow.www.extensions.init_cache import init_cache
from airflow.www.extensions.init_dagbag import init_dagbag
from airflow.www.extensions.init_jinja_globals import init_jinja_globals
@@ -168,9 +167,6 @@ def create_app(config=None, testing=False):
init_api_internal(flask_app)
init_api_auth_provider(flask_app)
init_api_error_handlers(flask_app) # needs to be after all api inits
to let them add their path first
-
- get_auth_manager().init()
-
init_jinja_globals(flask_app)
init_xframe_protection(flask_app)
init_cache_control(flask_app)
diff --git a/airflow/www/extensions/init_auth_manager.py
b/airflow/www/extensions/init_auth_manager.py
index f69734ce8a..6e6f1f8af1 100644
--- a/airflow/www/extensions/init_auth_manager.py
+++ b/airflow/www/extensions/init_auth_manager.py
@@ -54,6 +54,7 @@ def init_auth_manager(appbuilder: AirflowAppBuilder) ->
BaseAuthManager:
global auth_manager
auth_manager_cls = get_auth_manager_cls()
auth_manager = auth_manager_cls(appbuilder)
+ auth_manager.init()
return auth_manager
diff --git a/airflow/www/static/js/login/Form.tsx
b/airflow/www/static/js/login/Form.tsx
new file mode 100644
index 0000000000..e8ca124f5a
--- /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 = () => (
+ <form action={getMetaValue("login_submit_url")} method="post">
+ <Stack spacing={4}>
+ <FormControl isRequired>
+ <FormLabel>Username</FormLabel>
+ <Input name="username" />
+ </FormControl>
+
+ <FormControl isRequired>
+ <FormLabel>Password</FormLabel>
+ <Input name="password" type="password" />
+ </FormControl>
+
+ <Button colorScheme="blue" type="submit">
+ Sign in
+ </Button>
+ </Stack>
+ </form>
+);
+
+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 0000000000..9d8b0988db
--- /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(<Login />, {
+ 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 0000000000..2153eef354
--- /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 (
+ <Container maxW="2xl" p="4" border="1px" borderColor="gray.500">
+ <Heading mb={6} fontWeight="normal" size="lg">
+ Sign in
+ </Heading>
+
+ {error && (
+ <Alert status="warning" mb="2">
+ <AlertIcon />
+ Invalid credentials, please try again.
+ </Alert>
+ )}
+
+ <Text mb={4}>Enter your login and password below:</Text>
+ <LoginForm />
+ </Container>
+ );
+};
+
+export default Login;
+
+if (mainElement) {
+ shadowRoot?.appendChild(mainElement);
+ const reactRoot = createRoot(mainElement);
+ reactRoot.render(
+ <App cache={cache}>
+ <Login />
+ </App>
+ );
+}
diff --git a/airflow/www/templates/airflow/login.html
b/airflow/www/templates/airflow/login.html
new file mode 100644
index 0000000000..5a25fb3b5f
--- /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() }}
+ <meta name="login_submit_url" content="{{
url_for('SimpleAuthManagerAuthenticationViews.login_submit') }}">
+{% endblock %}
+
+{% block messages %}
+{% endblock %}
+
+{% block content %}
+ {{ super() }}
+ <div id="root">
+ <div id="react-container"></div>
+ </div>
+{% endblock %}
+
+{% block tail_js %}
+ {{ super()}}
+ <script>
+ const stateColors = {{ state_color_mapping|tojson }};
+ const autoRefreshInterval = {{ auto_refresh_interval }};
+ const standaloneDagProcessor = {{ standalone_dag_processor|tojson }} ===
true ;
+ </script>
+ <script src="{{ url_for_asset('login.js') }}"></script>
+{% endblock %}
diff --git a/airflow/www/templates/appbuilder/navbar.html
b/airflow/www/templates/appbuilder/navbar.html
index 141d778d23..6f22d65343 100644
--- a/airflow/www/templates/appbuilder/navbar.html
+++ b/airflow/www/templates/appbuilder/navbar.html
@@ -47,7 +47,9 @@
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
+ {%- if disable_nav_bar is not defined or not disable_nav_bar -%}
{% include 'appbuilder/navbar_menu.html' %}
+ {%- endif -%}
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="active">
diff --git a/airflow/www/webpack.config.js b/airflow/www/webpack.config.js
index df4e11042c..45d3f90a4d 100644
--- a/airflow/www/webpack.config.js
+++ b/airflow/www/webpack.config.js
@@ -66,6 +66,7 @@ const config = {
flash: `${CSS_DIR}/flash.css`,
graph: `${CSS_DIR}/graph.css`,
loadingDots: `${CSS_DIR}/loading-dots.css`,
+ login: `${JS_DIR}/login/index.tsx`,
main: [`${CSS_DIR}/main.css`, `${JS_DIR}/main.js`],
materialIcons: `${CSS_DIR}/material-icons.css`,
moment: "moment-timezone",
diff --git a/tests/auth/managers/simple/__init__.py
b/tests/auth/managers/simple/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/tests/auth/managers/simple/__init__.py
@@ -0,0 +1,16 @@
+# 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/tests/auth/managers/simple/test_simple_auth_manager.py
b/tests/auth/managers/simple/test_simple_auth_manager.py
new file mode 100644
index 0000000000..3191069860
--- /dev/null
+++ b/tests/auth/managers/simple/test_simple_auth_manager.py
@@ -0,0 +1,274 @@
+# 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 json
+from unittest.mock import Mock, patch
+
+import pytest
+from flask import Flask, session
+
+from airflow.auth.managers.models.resource_details import AccessView
+from airflow.auth.managers.simple.simple_auth_manager import SimpleAuthManager
+from airflow.auth.managers.simple.user import SimpleAuthManagerUser
+from airflow.auth.managers.simple.views.auth import
SimpleAuthManagerAuthenticationViews
+from airflow.www.extensions.init_appbuilder import init_appbuilder
+
+
[email protected]
+def auth_manager():
+ return SimpleAuthManager(None)
+
+
[email protected]
+def auth_manager_with_appbuilder():
+ flask_app = Flask(__name__)
+ appbuilder = init_appbuilder(flask_app)
+ return SimpleAuthManager(appbuilder)
+
+
[email protected]
+def test_user():
+ return SimpleAuthManagerUser(username="test", role="test")
+
+
+class TestSimpleAuthManager:
+ @pytest.mark.db_test
+ def test_init_with_no_user(self, auth_manager_with_appbuilder):
+ auth_manager_with_appbuilder.init()
+ with open(SimpleAuthManager.GENERATED_PASSWORDS_FILE) as file:
+ passwords_str = file.read().strip()
+ user_passwords_from_file = json.loads(passwords_str)
+
+ assert user_passwords_from_file == {}
+
+ @pytest.mark.db_test
+ def test_init_with_users(self, auth_manager_with_appbuilder):
+
auth_manager_with_appbuilder.appbuilder.app.config["SIMPLE_AUTH_MANAGER_USERS"]
= [
+ {
+ "username": "test",
+ "role": "admin",
+ }
+ ]
+ auth_manager_with_appbuilder.init()
+ with open(SimpleAuthManager.GENERATED_PASSWORDS_FILE) as file:
+ passwords_str = file.read().strip()
+ user_passwords_from_file = json.loads(passwords_str)
+
+ assert len(user_passwords_from_file) == 1
+
+ @pytest.mark.db_test
+ def test_is_logged_in(self, auth_manager, app, test_user):
+ with app.test_request_context():
+ session["user"] = test_user
+ result = auth_manager.is_logged_in()
+ assert result
+
+ @pytest.mark.db_test
+ def test_is_logged_in_return_false_when_no_user_in_session(self,
auth_manager, app, test_user):
+ with app.test_request_context():
+ result = auth_manager.is_logged_in()
+
+ assert result is False
+
+ @patch("airflow.auth.managers.simple.simple_auth_manager.url_for")
+ def test_get_url_login(self, mock_url_for, auth_manager):
+ auth_manager.get_url_login()
+
mock_url_for.assert_called_once_with("SimpleAuthManagerAuthenticationViews.login")
+
+ @patch("airflow.auth.managers.simple.simple_auth_manager.url_for")
+ def test_get_url_logout(self, mock_url_for, auth_manager):
+ auth_manager.get_url_logout()
+
mock_url_for.assert_called_once_with("SimpleAuthManagerAuthenticationViews.logout")
+
+ @pytest.mark.db_test
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ def test_get_user(self, mock_is_logged_in, auth_manager, app, test_user):
+ mock_is_logged_in.return_value = True
+
+ with app.test_request_context():
+ session["user"] = test_user
+ result = auth_manager.get_user()
+
+ assert result == test_user
+
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ def test_get_user_return_none_when_not_logged_in(self, mock_is_logged_in,
auth_manager):
+ mock_is_logged_in.return_value = False
+ result = auth_manager.get_user()
+
+ assert result is None
+
+ @pytest.mark.db_test
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ @pytest.mark.parametrize(
+ "api",
+ [
+ "is_authorized_configuration",
+ "is_authorized_connection",
+ "is_authorized_dag",
+ "is_authorized_dataset",
+ "is_authorized_pool",
+ "is_authorized_variable",
+ ],
+ )
+ @pytest.mark.parametrize(
+ "is_logged_in, role, method, result",
+ [
+ (True, "ADMIN", "GET", True),
+ (True, "ADMIN", "DELETE", True),
+ (True, "VIEWER", "POST", False),
+ (True, "VIEWER", "PUT", False),
+ (True, "VIEWER", "DELETE", False),
+ (False, "ADMIN", "GET", False),
+ ],
+ )
+ def test_is_authorized_methods(
+ self, mock_is_logged_in, auth_manager, app, api, is_logged_in, role,
method, result
+ ):
+ mock_is_logged_in.return_value = is_logged_in
+
+ with app.test_request_context():
+ session["user"] = SimpleAuthManagerUser(username="test", role=role)
+ assert getattr(auth_manager, api)(method=method) is result
+
+ @pytest.mark.db_test
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ @pytest.mark.parametrize(
+ "api, kwargs",
+ [
+ ("is_authorized_view", {"access_view":
AccessView.CLUSTER_ACTIVITY}),
+ (
+ "is_authorized_custom_view",
+ {
+ "method": "GET",
+ "resource_name": "test",
+ },
+ ),
+ ],
+ )
+ @pytest.mark.parametrize(
+ "is_logged_in, role, result",
+ [
+ (True, "ADMIN", True),
+ (True, "VIEWER", True),
+ (True, "USER", True),
+ (True, "OP", True),
+ (False, "ADMIN", False),
+ ],
+ )
+ def test_is_authorized_view_methods(
+ self, mock_is_logged_in, auth_manager, app, api, kwargs, is_logged_in,
role, result
+ ):
+ mock_is_logged_in.return_value = is_logged_in
+
+ with app.test_request_context():
+ session["user"] = SimpleAuthManagerUser(username="test", role=role)
+ assert getattr(auth_manager, api)(**kwargs) is result
+
+ @pytest.mark.db_test
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ @pytest.mark.parametrize(
+ "api",
+ [
+ "is_authorized_configuration",
+ "is_authorized_connection",
+ "is_authorized_dataset",
+ "is_authorized_pool",
+ "is_authorized_variable",
+ ],
+ )
+ @pytest.mark.parametrize(
+ "role, method, result",
+ [
+ ("ADMIN", "GET", True),
+ ("OP", "DELETE", True),
+ ("USER", "DELETE", False),
+ ("VIEWER", "PUT", False),
+ ],
+ )
+ def test_is_authorized_methods_op_role_required(
+ self, mock_is_logged_in, auth_manager, app, api, role, method, result
+ ):
+ mock_is_logged_in.return_value = True
+
+ with app.test_request_context():
+ session["user"] = SimpleAuthManagerUser(username="test", role=role)
+ assert getattr(auth_manager, api)(method=method) is result
+
+ @pytest.mark.db_test
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ @pytest.mark.parametrize(
+ "api",
+ ["is_authorized_dag"],
+ )
+ @pytest.mark.parametrize(
+ "role, method, result",
+ [
+ ("ADMIN", "GET", True),
+ ("OP", "DELETE", True),
+ ("USER", "GET", True),
+ ("USER", "DELETE", True),
+ ("VIEWER", "PUT", False),
+ ],
+ )
+ def test_is_authorized_methods_user_role_required(
+ self, mock_is_logged_in, auth_manager, app, api, role, method, result
+ ):
+ mock_is_logged_in.return_value = True
+
+ with app.test_request_context():
+ session["user"] = SimpleAuthManagerUser(username="test", role=role)
+ assert getattr(auth_manager, api)(method=method) is result
+
+ @pytest.mark.db_test
+ @patch.object(SimpleAuthManager, "is_logged_in")
+ @pytest.mark.parametrize(
+ "api",
+ ["is_authorized_dag", "is_authorized_dataset", "is_authorized_pool"],
+ )
+ @pytest.mark.parametrize(
+ "role, method, result",
+ [
+ ("ADMIN", "GET", True),
+ ("VIEWER", "GET", True),
+ ("OP", "GET", True),
+ ("USER", "GET", True),
+ ("VIEWER", "POST", False),
+ ],
+ )
+ def test_is_authorized_methods_viewer_role_required_for_get(
+ self, mock_is_logged_in, auth_manager, app, api, role, method, result
+ ):
+ mock_is_logged_in.return_value = True
+
+ with app.test_request_context():
+ session["user"] = SimpleAuthManagerUser(username="test", role=role)
+ assert getattr(auth_manager, api)(method=method) is result
+
+ @pytest.mark.db_test
+ @patch(
+
"airflow.providers.amazon.aws.auth_manager.views.auth.conf.get_mandatory_value",
return_value="test"
+ )
+ def test_register_views(self, _, auth_manager_with_appbuilder):
+ auth_manager_with_appbuilder.appbuilder.add_view_no_menu = Mock()
+ auth_manager_with_appbuilder.register_views()
+
auth_manager_with_appbuilder.appbuilder.add_view_no_menu.assert_called_once()
+ assert isinstance(
+
auth_manager_with_appbuilder.appbuilder.add_view_no_menu.call_args.args[0],
+ SimpleAuthManagerAuthenticationViews,
+ )
diff --git a/tests/auth/managers/simple/test_user.py
b/tests/auth/managers/simple/test_user.py
new file mode 100644
index 0000000000..bdc649811b
--- /dev/null
+++ b/tests/auth/managers/simple/test_user.py
@@ -0,0 +1,37 @@
+# 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 pytest
+
+from airflow.auth.managers.simple.user import SimpleAuthManagerUser
+
+
[email protected]
+def user():
+ return SimpleAuthManagerUser(username="test", role="admin")
+
+
+class TestSimpleAuthManagerUser:
+ def test_get_id(self, user):
+ assert user.get_id() == "test"
+
+ def test_get_name(self, user):
+ assert user.get_name() == "test"
+
+ def test_get_role(self, user):
+ assert user.get_role() == "admin"
diff --git a/tests/auth/managers/simple/views/__init__.py
b/tests/auth/managers/simple/views/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/tests/auth/managers/simple/views/__init__.py
@@ -0,0 +1,16 @@
+# 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/tests/auth/managers/simple/views/test_auth.py
b/tests/auth/managers/simple/views/test_auth.py
new file mode 100644
index 0000000000..197ed0e615
--- /dev/null
+++ b/tests/auth/managers/simple/views/test_auth.py
@@ -0,0 +1,76 @@
+# 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 json
+
+import pytest
+from flask import session, url_for
+
+from airflow.auth.managers.simple.simple_auth_manager import SimpleAuthManager
+from airflow.www import app as application
+from tests.test_utils.config import conf_vars
+
+
[email protected]
+def simple_app():
+ with conf_vars(
+ {
+ (
+ "core",
+ "auth_manager",
+ ):
"airflow.auth.managers.simple.simple_auth_manager.SimpleAuthManager",
+ }
+ ):
+ with open(SimpleAuthManager.GENERATED_PASSWORDS_FILE, "w") as file:
+ user = {"test": "test"}
+ file.write(json.dumps(user))
+
+ return application.create_app(
+ testing=True,
+ config={
+ "SIMPLE_AUTH_MANAGER_USERS": [
+ {
+ "username": "test",
+ "role": "admin",
+ }
+ ]
+ },
+ )
+
+
[email protected]_test
+class TestSimpleAuthManagerAuthenticationViews:
+ def test_logout_redirects_to_login_and_clear_user(self, simple_app):
+ with simple_app.test_client() as client:
+ response = client.get("/logout")
+ assert response.status_code == 302
+ assert response.location == "/login"
+ assert session.get("user") is None
+
+ @pytest.mark.parametrize(
+ "username, password, is_successful",
+ [("test", "test", True), ("test", "test2", False), ("", "", False)],
+ )
+ def test_login_submit(self, simple_app, username, password, is_successful):
+ with simple_app.test_client() as client:
+ response = client.post("/login_submit", data={"username":
username, "password": password})
+ assert response.status_code == 302
+ if is_successful:
+ assert response.location == url_for("Airflow.index")
+ else:
+ assert response.location ==
url_for("SimpleAuthManagerAuthenticationViews.login", error=["1"])