From 1472fa0b23719449f429398c5d09b38c28354256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Tue, 12 Sep 2023 15:28:01 -0700 Subject: [PATCH 01/17] move user & role API endpoints to fab folder --- airflow/api_connexion/openapi/v1.yaml | 22 +++++++++---------- .../managers/fab/api_endpoints/__init__.py | 16 ++++++++++++++ .../role_and_permission_endpoint.py | 0 .../fab/api_endpoints}/user_endpoint.py | 0 4 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 airflow/auth/managers/fab/api_endpoints/__init__.py rename airflow/{api_connexion/endpoints => auth/managers/fab/api_endpoints}/role_and_permission_endpoint.py (100%) rename airflow/{api_connexion/endpoints => auth/managers/fab/api_endpoints}/user_endpoint.py (100%) diff --git a/airflow/api_connexion/openapi/v1.yaml b/airflow/api_connexion/openapi/v1.yaml index af9d0cf8a465e..ea455f4557aa4 100644 --- a/airflow/api_connexion/openapi/v1.yaml +++ b/airflow/api_connexion/openapi/v1.yaml @@ -2117,7 +2117,7 @@ paths: Get a list of roles. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: get_roles tags: [Role] parameters: @@ -2142,7 +2142,7 @@ paths: Create a new role. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: post_role tags: [Role] requestBody: @@ -2175,7 +2175,7 @@ paths: Get a role. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: get_role tags: [Role] responses: @@ -2198,7 +2198,7 @@ paths: Update a role. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: patch_role tags: [Role] parameters: @@ -2232,7 +2232,7 @@ paths: Delete a role. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: delete_role tags: [Role] responses: @@ -2254,7 +2254,7 @@ paths: Get a list of permissions. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: get_permissions tags: [Permission] parameters: @@ -2279,7 +2279,7 @@ paths: Get a list of users. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.user_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: get_users tags: [User] parameters: @@ -2304,7 +2304,7 @@ paths: Create a new user with unique username and email. *New in version 2.2.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.user_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: post_user tags: [User] requestBody: @@ -2338,7 +2338,7 @@ paths: Get a user with a specific username. *New in version 2.1.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.user_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: get_user tags: [User] responses: @@ -2361,7 +2361,7 @@ paths: Update fields for a user. *New in version 2.2.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.user_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: patch_user tags: [User] parameters: @@ -2394,7 +2394,7 @@ paths: Delete a user with a specific username. *New in version 2.2.0* - x-openapi-router-controller: airflow.api_connexion.endpoints.user_endpoint + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: delete_user tags: [User] responses: diff --git a/airflow/auth/managers/fab/api_endpoints/__init__.py b/airflow/auth/managers/fab/api_endpoints/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/airflow/auth/managers/fab/api_endpoints/__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/airflow/api_connexion/endpoints/role_and_permission_endpoint.py b/airflow/auth/managers/fab/api_endpoints/role_and_permission_endpoint.py similarity index 100% rename from airflow/api_connexion/endpoints/role_and_permission_endpoint.py rename to airflow/auth/managers/fab/api_endpoints/role_and_permission_endpoint.py diff --git a/airflow/api_connexion/endpoints/user_endpoint.py b/airflow/auth/managers/fab/api_endpoints/user_endpoint.py similarity index 100% rename from airflow/api_connexion/endpoints/user_endpoint.py rename to airflow/auth/managers/fab/api_endpoints/user_endpoint.py From a3632f9d7bf90805e7f335302210fd2980f8f05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 09:30:41 -0700 Subject: [PATCH 02/17] add a method to init API from auth provider --- airflow/auth/managers/base_auth_manager.py | 6 +++++- airflow/www/app.py | 2 ++ airflow/www/extensions/init_views.py | 10 ++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/airflow/auth/managers/base_auth_manager.py b/airflow/auth/managers/base_auth_manager.py index 66b79b0a26daf..212095a304076 100644 --- a/airflow/auth/managers/base_auth_manager.py +++ b/airflow/auth/managers/base_auth_manager.py @@ -24,7 +24,7 @@ from airflow.utils.log.logging_mixin import LoggingMixin if TYPE_CHECKING: - from flask import Flask + from flask import Blueprint, Flask from airflow.auth.managers.models.base_user import BaseUser from airflow.cli.cli_config import CLICommand @@ -50,6 +50,10 @@ def get_cli_commands() -> list[CLICommand]: """ return [] + def get_blueprint(self) -> None | Blueprint: + """Return a blueprint of the API endpoints proposed by this auth manager.""" + return None + @abstractmethod def get_user_name(self) -> str: """Return the username associated to the user in session.""" diff --git a/airflow/www/app.py b/airflow/www/app.py index 9ac01ef5b5d2c..f469ca2376587 100644 --- a/airflow/www/app.py +++ b/airflow/www/app.py @@ -48,6 +48,7 @@ ) from airflow.www.extensions.init_session import init_airflow_session_interface from airflow.www.extensions.init_views import ( + init_api_auth_provider, init_api_connexion, init_api_experimental, init_api_internal, @@ -169,6 +170,7 @@ def create_app(config=None, testing=False): raise RuntimeError("The AIP_44 is not enabled so you cannot use it.") init_api_internal(flask_app) init_api_experimental(flask_app) + init_api_auth_provider(flask_app) sync_appbuilder_roles(flask_app) diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py index bb3c04608132a..1655bc98fd8b2 100644 --- a/airflow/www/extensions/init_views.py +++ b/airflow/www/extensions/init_views.py @@ -33,6 +33,7 @@ from airflow.exceptions import RemovedInAirflow3Warning from airflow.security import permissions from airflow.utils.yaml import safe_load +from airflow.www.extensions.init_auth_manager import get_auth_manager if TYPE_CHECKING: from flask import Flask @@ -310,3 +311,12 @@ def init_api_experimental(app): ) app.register_blueprint(endpoints.api_experimental, url_prefix="/api/experimental") app.extensions["csrf"].exempt(endpoints.api_experimental) + + +def init_api_auth_provider(app): + """Initialize the API offered by the authentication provider.""" + auth_mgr = get_auth_manager() + blueprint = auth_mgr.get_blueprint() + if blueprint is not None: + app.register_blueprint(blueprint) + app.extensions["csrf"].exempt(blueprint) From 7cfe9150a978d8ecdc480605d6c2b57e6e0eb743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 09:31:29 -0700 Subject: [PATCH 03/17] add user & role APIs in fab auth manager --- airflow/auth/managers/fab/fab_auth_manager.py | 26 + airflow/auth/managers/fab/openapi/v1.yaml | 712 ++++++++++++++++++ 2 files changed, 738 insertions(+) create mode 100644 airflow/auth/managers/fab/openapi/v1.yaml diff --git a/airflow/auth/managers/fab/fab_auth_manager.py b/airflow/auth/managers/fab/fab_auth_manager.py index 27c96d6dd596c..942e3d39b3159 100644 --- a/airflow/auth/managers/fab/fab_auth_manager.py +++ b/airflow/auth/managers/fab/fab_auth_manager.py @@ -18,8 +18,11 @@ from __future__ import annotations import warnings +from pathlib import Path from typing import TYPE_CHECKING +from connexion import FlaskApi + from airflow import AirflowException from airflow.auth.managers.base_auth_manager import BaseAuthManager from airflow.auth.managers.fab.cli_commands.definition import ( @@ -30,8 +33,13 @@ from airflow.cli.cli_config import ( GroupCommand, ) +from airflow.configuration import conf +from airflow.utils.yaml import safe_load +from airflow.www.extensions.init_views import _CustomErrorRequestBodyValidator, _LazyResolver if TYPE_CHECKING: + from flask import Blueprint + from airflow.auth.managers.fab.models import User from airflow.cli.cli_config import ( CLICommand, @@ -62,6 +70,24 @@ def get_cli_commands() -> list[CLICommand]: SYNC_PERM_COMMAND, # not in a command group ] + def get_blueprint(self) -> None | Blueprint: + """Return a blueprint of the API endpoints proposed by this auth manager.""" + folder = Path(__file__).parents[0].resolve() # this is airflow/auth/managers/fab/ + with folder.joinpath("openapi", "v1.yaml").open() as f: + specification = safe_load(f) + api = FlaskApi( + specification=specification, + resolver=_LazyResolver(), + base_path="/security/v1", + options={ + "swagger_ui": conf.getboolean("webserver", "enable_swagger_ui", fallback=True), + }, + strict_validation=True, + validate_responses=True, + validator_map={"body": _CustomErrorRequestBodyValidator}, + ) + return api.blueprint + def get_user_name(self) -> str: """ Return the username associated to the user in session. diff --git a/airflow/auth/managers/fab/openapi/v1.yaml b/airflow/auth/managers/fab/openapi/v1.yaml new file mode 100644 index 0000000000000..a6d9e65b569e1 --- /dev/null +++ b/airflow/auth/managers/fab/openapi/v1.yaml @@ -0,0 +1,712 @@ +# 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. +--- +openapi: 3.0.3 + +info: + title: "Flask App Builder User & Role API" + + version: '1.0.0' + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + contact: + name: Apache Software Foundation + url: https://airflow.apache.org + email: dev@airflow.apache.org + +paths: + /roles: + get: + summary: List roles + description: | + Get a list of roles. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + operationId: get_roles + tags: [Role] + parameters: + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/PageOffset' + - $ref: '#/components/parameters/OrderBy' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/RoleCollection' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + + post: + summary: Create a role + description: | + Create a new role. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + operationId: post_role + tags: [Role] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + + /roles/{role_name}: + parameters: + - $ref: '#/components/parameters/RoleName' + + get: + summary: Get a role + description: | + Get a role. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + operationId: get_role + tags: [Role] + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update a role + description: | + Update a role. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + operationId: patch_role + tags: [Role] + parameters: + - $ref: '#/components/parameters/UpdateMask' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/Role' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + + delete: + summary: Delete a role + description: | + Delete a role. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + operationId: delete_role + tags: [Role] + responses: + '204': + description: Success. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + + /permissions: + get: + summary: List permissions + description: | + Get a list of permissions. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + operationId: get_permissions + tags: [Permission] + parameters: + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/PageOffset' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/ActionCollection' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + + /users: + get: + summary: List users + description: | + Get a list of users. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + operationId: get_users + tags: [User] + parameters: + - $ref: '#/components/parameters/PageLimit' + - $ref: '#/components/parameters/PageOffset' + - $ref: '#/components/parameters/OrderBy' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/UserCollection' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + + post: + summary: Create a user + description: | + Create a new user with unique username and email. + + *New in version 2.2.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + operationId: post_user + tags: [User] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '409': + $ref: '#/components/responses/AlreadyExists' + + /users/{username}: + parameters: + - $ref: '#/components/parameters/Username' + get: + summary: Get a user + description: | + Get a user with a specific username. + + *New in version 2.1.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + operationId: get_user + tags: [User] + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/UserCollectionItem' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update a user + description: | + Update fields for a user. + + *New in version 2.2.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + operationId: patch_user + tags: [User] + parameters: + - $ref: '#/components/parameters/UpdateMask' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Success. + content: + application/json: + schema: + $ref: '#/components/schemas/UserCollectionItem' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + + delete: + summary: Delete a user + description: | + Delete a user with a specific username. + + *New in version 2.2.0* + x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + operationId: delete_user + tags: [User] + responses: + '204': + description: Success. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/PermissionDenied' + '404': + $ref: '#/components/responses/NotFound' + +components: + # Reusable schemas (data models) + schemas: + # Database entities + UserCollectionItem: + description: | + A user object. + + *New in version 2.1.0* + type: object + properties: + first_name: + type: string + description: | + The user's first name. + + *Changed in version 2.4.0*: The requirement for this to be non-empty was removed. + last_name: + type: string + description: | + The user's last name. + + *Changed in version 2.4.0*: The requirement for this to be non-empty was removed. + username: + type: string + description: | + The username. + + *Changed in version 2.2.0*: A minimum character length requirement ('minLength') is added. + minLength: 1 + email: + type: string + description: | + The user's email. + + *Changed in version 2.2.0*: A minimum character length requirement ('minLength') is added. + minLength: 1 + active: + type: boolean + description: Whether the user is active + readOnly: true + nullable: true + last_login: + type: string + format: datetime + description: The last user login + readOnly: true + nullable: true + login_count: + type: integer + description: The login count + readOnly: true + nullable: true + failed_login_count: + type: integer + description: The number of times the login failed + readOnly: true + nullable: true + roles: + type: array + description: | + User roles. + + *Changed in version 2.2.0*: Field is no longer read-only. + items: + type: object + properties: + name: + type: string + nullable: true + created_on: + type: string + format: datetime + description: The date user was created + readOnly: true + nullable: true + changed_on: + type: string + format: datetime + description: The date user was changed + readOnly: true + nullable: true + User: + type: object + description: | + A user object with sensitive data. + + *New in version 2.1.0* + allOf: + - $ref: '#/components/schemas/UserCollectionItem' + - type: object + properties: + password: + type: string + writeOnly: true + + UserCollection: + type: object + description: | + Collection of users. + + *New in version 2.1.0* + allOf: + - type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/UserCollectionItem' + - $ref: '#/components/schemas/CollectionInfo' + + Role: + description: | + a role item. + + *New in version 2.1.0* + type: object + properties: + name: + type: string + description: | + The name of the role + + *Changed in version 2.3.0*: A minimum character length requirement ('minLength') is added. + minLength: 1 + actions: + type: array + items: + $ref: '#/components/schemas/ActionResource' + + RoleCollection: + description: | + A collection of roles. + + *New in version 2.1.0* + type: object + allOf: + - type: object + properties: + roles: + type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/CollectionInfo' + + Action: + description: | + An action Item. + + *New in version 2.1.0* + type: object + properties: + name: + type: string + description: The name of the permission "action" + nullable: false + + ActionCollection: + description: | + A collection of actions. + + *New in version 2.1.0* + type: object + allOf: + - type: object + properties: + actions: + type: array + items: + $ref: '#/components/schemas/Action' + - $ref: '#/components/schemas/CollectionInfo' + + Resource: + description: | + A resource on which permissions are granted. + + *New in version 2.1.0* + type: object + properties: + name: + type: string + description: The name of the resource + nullable: false + + ActionResource: + description: | + The Action-Resource item. + + *New in version 2.1.0* + type: object + properties: + action: + type: object + $ref: '#/components/schemas/Action' + description: The permission action + resource: + type: object + $ref: '#/components/schemas/Resource' + description: The permission resource + + # Generic + Error: + description: | + [RFC7807](https://tools.ietf.org/html/rfc7807) compliant response. + type: object + properties: + type: + type: string + description: | + A URI reference [RFC3986] that identifies the problem type. This specification + encourages that, when dereferenced, it provide human-readable documentation for + the problem type. + title: + type: string + description: A short, human-readable summary of the problem type. + status: + type: number + description: The HTTP status code generated by the API server for this occurrence of the problem. + detail: + type: string + description: A human-readable explanation specific to this occurrence of the problem. + instance: + type: string + description: | + A URI reference that identifies the specific occurrence of the problem. It may or may + not yield further information if dereferenced. + required: + - type + - title + - status + + CollectionInfo: + description: Metadata about collection. + type: object + properties: + total_entries: + type: integer + description: | + Count of total objects in the current result set before pagination parameters + (limit, offset) are applied. + + + # Reusable path, query, header and cookie parameters + parameters: + # Pagination parameters + PageOffset: + in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + description: The number of items to skip before starting to collect the result set. + + PageLimit: + in: query + name: limit + required: false + schema: + type: integer + default: 100 + description: The numbers of items to return. + + # Database entity fields + Username: + in: path + name: username + schema: + type: string + required: true + description: | + The username of the user. + + *New in version 2.1.0* + RoleName: + in: path + name: role_name + schema: + type: string + required: true + description: The role name + + OrderBy: + in: query + name: order_by + schema: + type: string + required: false + description: | + The name of the field to order the results by. + Prefix a field name with `-` to reverse the sort order. + + *New in version 2.1.0* + + UpdateMask: + in: query + name: update_mask + schema: + type: array + items: + type: string + description: | + The fields to update on the resource. If absent or empty, all modifiable fields are updated. + A comma-separated list of fully qualified names of fields. + style: form + explode: false + + # Reusable responses, such as 401 Unauthenticated or 400 Bad Request + responses: + # 400 + 'BadRequest': + description: Client specified an invalid argument. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 401 + 'Unauthenticated': + description: Request not authenticated due to missing, invalid, authentication info. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 403 + 'PermissionDenied': + description: Client does not have sufficient permission. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 404 + 'NotFound': + description: A specified resource is not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 405 + 'MethodNotAllowed': + description: Request method is known by the server but is not supported by the target resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 406 + 'NotAcceptable': + description: A specified Accept header is not allowed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 409 + 'AlreadyExists': + description: An existing resource conflicts with the request. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # 500 + 'Unknown': + description: Unknown server error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + securitySchemes: + Basic: + type: http + scheme: basic + GoogleOpenId: + type: openIdConnect + openIdConnectUrl: https://accounts.google.com/.well-known/openid-configuration + Kerberos: + type: http + scheme: negotiate + +tags: + - name: Role + - name: Permission + - name: User From 43f3e5d6b6cd381b0b2c346aaeb5fc06e931bf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 09:49:13 -0700 Subject: [PATCH 04/17] mark existing api endpoints as deprecated --- airflow/api_connexion/openapi/v1.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/airflow/api_connexion/openapi/v1.yaml b/airflow/api_connexion/openapi/v1.yaml index ea455f4557aa4..7847e0acdb740 100644 --- a/airflow/api_connexion/openapi/v1.yaml +++ b/airflow/api_connexion/openapi/v1.yaml @@ -2112,6 +2112,7 @@ paths: /roles: get: + deprecated: true summary: List roles description: | Get a list of roles. @@ -2137,6 +2138,7 @@ paths: $ref: '#/components/responses/PermissionDenied' post: + deprecated: true summary: Create a role description: | Create a new role. @@ -2170,6 +2172,7 @@ paths: - $ref: '#/components/parameters/RoleName' get: + deprecated: true summary: Get a role description: | Get a role. @@ -2193,6 +2196,7 @@ paths: $ref: '#/components/responses/NotFound' patch: + deprecated: true summary: Update a role description: | Update a role. @@ -2227,6 +2231,7 @@ paths: $ref: '#/components/responses/NotFound' delete: + deprecated: true summary: Delete a role description: | Delete a role. @@ -2249,6 +2254,7 @@ paths: /permissions: get: + deprecated: true summary: List permissions description: | Get a list of permissions. @@ -2274,6 +2280,7 @@ paths: /users: get: + deprecated: true summary: List users description: | Get a list of users. @@ -2299,6 +2306,7 @@ paths: $ref: '#/components/responses/PermissionDenied' post: + deprecated: true summary: Create a user description: | Create a new user with unique username and email. @@ -2333,6 +2341,7 @@ paths: parameters: - $ref: '#/components/parameters/Username' get: + deprecated: true summary: Get a user description: | Get a user with a specific username. @@ -2356,6 +2365,7 @@ paths: $ref: '#/components/responses/NotFound' patch: + deprecated: true summary: Update a user description: | Update fields for a user. @@ -2389,6 +2399,7 @@ paths: $ref: '#/components/responses/NotFound' delete: + deprecated: true summary: Delete a user description: | Delete a user with a specific username. From 3c0490d9660d0f52df9df75ee0e21fb9e21ee1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 11:39:46 -0700 Subject: [PATCH 05/17] move tests --- .../managers/fab/api_endpoints/__init__.py | 16 ++++++++ .../managers/fab/api_endpoints/conftest.py | 38 +++++++++++++++++++ .../test_role_and_permission_endpoint.py | 4 +- .../fab/api_endpoints}/test_user_endpoint.py | 6 +-- tests/test_utils/decorators.py | 1 + 5 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/auth/managers/fab/api_endpoints/__init__.py create mode 100644 tests/auth/managers/fab/api_endpoints/conftest.py rename tests/{api_connexion/endpoints => auth/managers/fab/api_endpoints}/test_role_and_permission_endpoint.py (99%) rename tests/{api_connexion/endpoints => auth/managers/fab/api_endpoints}/test_user_endpoint.py (99%) diff --git a/tests/auth/managers/fab/api_endpoints/__init__.py b/tests/auth/managers/fab/api_endpoints/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/auth/managers/fab/api_endpoints/__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/fab/api_endpoints/conftest.py b/tests/auth/managers/fab/api_endpoints/conftest.py new file mode 100644 index 0000000000000..6bcc614564d4d --- /dev/null +++ b/tests/auth/managers/fab/api_endpoints/conftest.py @@ -0,0 +1,38 @@ +# 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.www import app +from tests.test_utils.config import conf_vars +from tests.test_utils.decorators import dont_initialize_flask_app_submodules + + +@pytest.fixture(scope="session") +def minimal_app_for_auth_api(): + @dont_initialize_flask_app_submodules( + skip_all_except=[ + "init_appbuilder", + "init_api_auth_provider", + ] + ) + def factory(): + with conf_vars({("api", "auth_backends"): "tests.test_utils.remote_user_api_auth_backend"}): + return app.create_app(testing=True, config={"WTF_CSRF_ENABLED": False}) # type:ignore + + return factory() diff --git a/tests/api_connexion/endpoints/test_role_and_permission_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py similarity index 99% rename from tests/api_connexion/endpoints/test_role_and_permission_endpoint.py rename to tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py index bdede1f16ffc0..fca735588af2b 100644 --- a/tests/api_connexion/endpoints/test_role_and_permission_endpoint.py +++ b/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py @@ -32,8 +32,8 @@ @pytest.fixture(scope="module") -def configured_app(minimal_app_for_api): - app = minimal_app_for_api +def configured_app(minimal_app_for_auth_api): + app = minimal_app_for_auth_api create_user( app, # type: ignore username="test", diff --git a/tests/api_connexion/endpoints/test_user_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py similarity index 99% rename from tests/api_connexion/endpoints/test_user_endpoint.py rename to tests/auth/managers/fab/api_endpoints/test_user_endpoint.py index ac0c48f689212..44c005f8f85e1 100644 --- a/tests/api_connexion/endpoints/test_user_endpoint.py +++ b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py @@ -33,8 +33,8 @@ @pytest.fixture(scope="module") -def configured_app(minimal_app_for_api): - app = minimal_app_for_api +def configured_app(minimal_app_for_auth_api): + app = minimal_app_for_auth_api create_user( app, # type: ignore username="test", @@ -633,7 +633,7 @@ def test_username_can_be_updated(self, autoclean_user_payload, autoclean_usernam @pytest.mark.usefixtures("autoclean_admin_user") @unittest.mock.patch( - "airflow.api_connexion.endpoints.user_endpoint.generate_password_hash", + "airflow.auth.managers.fab.api_endpoints.user_endpoint.generate_password_hash", return_value="fake-hashed-pass", ) def test_password_hashed( diff --git a/tests/test_utils/decorators.py b/tests/test_utils/decorators.py index 522f80a25436d..599db286dec2b 100644 --- a/tests/test_utils/decorators.py +++ b/tests/test_utils/decorators.py @@ -40,6 +40,7 @@ def no_op(*args, **kwargs): "init_api_connexion", "init_api_internal", "init_api_experimental", + "init_api_auth_provider", "sync_appbuilder_roles", "init_jinja_globals", "init_xframe_protection", From f67eac301e0c5781c076428a759aaccdcaae351a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 12:53:26 -0700 Subject: [PATCH 06/17] test fixes - fix paths - add error handling to all existing endpoints --- airflow/www/app.py | 2 + airflow/www/extensions/init_views.py | 23 +++-- .../managers/fab/api_endpoints/conftest.py | 2 + .../test_role_and_permission_endpoint.py | 80 +++++++++------- .../fab/api_endpoints/test_user_endpoint.py | 96 ++++++++++--------- tests/test_utils/decorators.py | 1 + 6 files changed, 119 insertions(+), 85 deletions(-) diff --git a/airflow/www/app.py b/airflow/www/app.py index f469ca2376587..851528cfc3ce4 100644 --- a/airflow/www/app.py +++ b/airflow/www/app.py @@ -50,6 +50,7 @@ from airflow.www.extensions.init_views import ( init_api_auth_provider, init_api_connexion, + init_api_error_handlers, init_api_experimental, init_api_internal, init_appbuilder_views, @@ -171,6 +172,7 @@ def create_app(config=None, testing=False): init_api_internal(flask_app) init_api_experimental(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 sync_appbuilder_roles(flask_app) diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py index 1655bc98fd8b2..6aff56607942f 100644 --- a/airflow/www/extensions/init_views.py +++ b/airflow/www/extensions/init_views.py @@ -231,15 +231,16 @@ def validate_schema(self, data, url): return super().validate_schema(data, url) -def init_api_connexion(app: Flask) -> None: - """Initialize Stable API.""" - base_path = "/api/v1" +base_paths: list[str] = [] # contains the list of base paths that have api endpoints + +def init_api_error_handlers(app: Flask) -> None: + """Add error handlers for 404 and 405 errors for existing API paths.""" from airflow.www import views @app.errorhandler(404) def _handle_api_not_found(ex): - if request.path.startswith(base_path): + if any([request.path.startswith(p) for p in base_paths]): # 404 errors are never handled on the blueprint level # unless raised from a view func so actual 404 errors, # i.e. "no route for it" defined, need to be handled @@ -250,11 +251,19 @@ def _handle_api_not_found(ex): @app.errorhandler(405) def _handle_method_not_allowed(ex): - if request.path.startswith(base_path): + if any([request.path.startswith(p) for p in base_paths]): return common_error_handler(ex) else: return views.method_not_allowed(ex) + app.register_error_handler(ProblemException, common_error_handler) + + +def init_api_connexion(app: Flask) -> None: + """Initialize Stable API.""" + base_path = "/api/v1" + base_paths.append(base_path) + with ROOT_APP_DIR.joinpath("api_connexion", "openapi", "v1.yaml").open() as f: specification = safe_load(f) api_bp = FlaskApi( @@ -272,7 +281,6 @@ def _handle_method_not_allowed(ex): api_bp.after_request(set_cors_headers_on_response) app.register_blueprint(api_bp) - app.register_error_handler(ProblemException, common_error_handler) app.extensions["csrf"].exempt(api_bp) @@ -281,6 +289,7 @@ def init_api_internal(app: Flask, standalone_api: bool = False) -> None: if not standalone_api and not conf.getboolean("webserver", "run_internal_api", fallback=False): return + base_paths.append("/internal_api/v1") with ROOT_APP_DIR.joinpath("api_internal", "openapi", "internal_api_v1.yaml").open() as f: specification = safe_load(f) api_bp = FlaskApi( @@ -309,6 +318,7 @@ def init_api_experimental(app): "The authenticated user has full access.", RemovedInAirflow3Warning, ) + base_paths.append("/api/experimental") app.register_blueprint(endpoints.api_experimental, url_prefix="/api/experimental") app.extensions["csrf"].exempt(endpoints.api_experimental) @@ -318,5 +328,6 @@ def init_api_auth_provider(app): auth_mgr = get_auth_manager() blueprint = auth_mgr.get_blueprint() if blueprint is not None: + base_paths.append(blueprint.url_prefix) app.register_blueprint(blueprint) app.extensions["csrf"].exempt(blueprint) diff --git a/tests/auth/managers/fab/api_endpoints/conftest.py b/tests/auth/managers/fab/api_endpoints/conftest.py index 6bcc614564d4d..66707ef53d8e5 100644 --- a/tests/auth/managers/fab/api_endpoints/conftest.py +++ b/tests/auth/managers/fab/api_endpoints/conftest.py @@ -28,7 +28,9 @@ def minimal_app_for_auth_api(): @dont_initialize_flask_app_submodules( skip_all_except=[ "init_appbuilder", + "init_api_experimental_auth", "init_api_auth_provider", + "init_api_error_handlers", ] ) def factory(): diff --git a/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py index fca735588af2b..7b2e77b50b29e 100644 --- a/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py +++ b/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py @@ -74,12 +74,14 @@ def teardown_method(self): class TestGetRoleEndpoint(TestRoleEndpoint): def test_should_response_200(self): - response = self.client.get("/api/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json["name"] == "Admin" def test_should_respond_404(self): - response = self.client.get("/api/v1/roles/invalid-role", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get( + "/security/v1/roles/invalid-role", environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 404 assert { "detail": "Role with name 'invalid-role' was not found", @@ -89,19 +91,19 @@ def test_should_respond_404(self): } == response.json def test_should_raises_401_unauthenticated(self): - response = self.client.get("/api/v1/roles/Admin") + response = self.client.get("/security/v1/roles/Admin") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/api/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/security/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 class TestGetRolesEndpoint(TestRoleEndpoint): def test_should_response_200(self): - response = self.client.get("/api/v1/roles", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/roles", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 existing_roles = set(EXISTING_ROLES) existing_roles.update(["Test", "TestNoPermissions"]) @@ -110,19 +112,21 @@ def test_should_response_200(self): assert roles == existing_roles def test_should_raises_401_unauthenticated(self): - response = self.client.get("/api/v1/roles") + response = self.client.get("/security/v1/roles") assert_401(response) def test_should_raises_400_for_invalid_order_by(self): response = self.client.get( - "/api/v1/roles?order_by=invalid", environ_overrides={"REMOTE_USER": "test"} + "/security/v1/roles?order_by=invalid", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 400 msg = "Ordering with 'invalid' is disallowed or the attribute does not exist on the model" assert response.json["detail"] == msg def test_should_raise_403_forbidden(self): - response = self.client.get("/api/v1/roles", environ_overrides={"REMOTE_USER": "test_no_permissions"}) + response = self.client.get( + "/security/v1/roles", environ_overrides={"REMOTE_USER": "test_no_permissions"} + ) assert response.status_code == 403 @@ -130,20 +134,20 @@ class TestGetRolesEndpointPaginationandFilter(TestRoleEndpoint): @pytest.mark.parametrize( "url, expected_roles", [ - ("/api/v1/roles?limit=1", ["Admin"]), - ("/api/v1/roles?limit=2", ["Admin", "Op"]), + ("/security/v1/roles?limit=1", ["Admin"]), + ("/security/v1/roles?limit=2", ["Admin", "Op"]), ( - "/api/v1/roles?offset=1", + "/security/v1/roles?offset=1", ["Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], ), ( - "/api/v1/roles?offset=0", + "/security/v1/roles?offset=0", ["Admin", "Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], ), - ("/api/v1/roles?limit=1&offset=2", ["Public"]), - ("/api/v1/roles?limit=1&offset=1", ["Op"]), + ("/security/v1/roles?limit=1&offset=2", ["Public"]), + ("/security/v1/roles?limit=1&offset=1", ["Op"]), ( - "/api/v1/roles?limit=2&offset=2", + "/security/v1/roles?limit=2&offset=2", ["Public", "Test"], ), ], @@ -161,7 +165,7 @@ def test_can_handle_limit_and_offset(self, url, expected_roles): class TestGetPermissionsEndpoint(TestRoleEndpoint): def test_should_response_200(self): - response = self.client.get("/api/v1/permissions", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/permissions", environ_overrides={"REMOTE_USER": "test"}) actions = {i[0] for i in self.app.appbuilder.sm.get_all_permissions() if i} assert response.status_code == 200 assert response.json["total_entries"] == len(actions) @@ -169,12 +173,12 @@ def test_should_response_200(self): assert actions == returned_actions def test_should_raises_401_unauthenticated(self): - response = self.client.get("/api/v1/permissions") + response = self.client.get("/security/v1/permissions") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/api/v1/permissions", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/security/v1/permissions", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 @@ -185,7 +189,9 @@ def test_post_should_respond_200(self): "name": "Test2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], } - response = self.client.post("/api/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"}) + response = self.client.post( + "/security/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 200 role = self.app.appbuilder.sm.find_role("Test2") assert role is not None @@ -256,7 +262,9 @@ def test_post_should_respond_200(self): ], ) def test_post_should_respond_400_for_invalid_payload(self, payload, error_message): - response = self.client.post("/api/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"}) + response = self.client.post( + "/security/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 400 assert response.json == { "detail": error_message, @@ -270,7 +278,9 @@ def test_post_should_respond_409_already_exist(self): "name": "Test", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], } - response = self.client.post("/api/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"}) + response = self.client.post( + "/security/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 409 assert response.json == { "detail": "Role with name 'Test' already exists; please update with the PATCH endpoint", @@ -281,7 +291,7 @@ def test_post_should_respond_409_already_exist(self): def test_should_raises_401_unauthenticated(self): response = self.client.post( - "/api/v1/roles", + "/security/v1/roles", json={ "name": "Test2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], @@ -292,7 +302,7 @@ def test_should_raises_401_unauthenticated(self): def test_should_raise_403_forbidden(self): response = self.client.post( - "/api/v1/roles", + "/security/v1/roles", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], @@ -305,14 +315,16 @@ def test_should_raise_403_forbidden(self): class TestDeleteRole(TestRoleEndpoint): def test_delete_should_respond_204(self, session): role = create_role(self.app, "mytestrole") - response = self.client.delete(f"/api/v1/roles/{role.name}", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.delete( + f"/security/v1/roles/{role.name}", environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 204 role_obj = session.query(Role).filter(Role.name == role.name).all() assert len(role_obj) == 0 def test_delete_should_respond_404(self): response = self.client.delete( - "/api/v1/roles/invalidrolename", environ_overrides={"REMOTE_USER": "test"} + "/security/v1/roles/invalidrolename", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 404 assert response.json == { @@ -323,13 +335,13 @@ def test_delete_should_respond_404(self): } def test_should_raises_401_unauthenticated(self): - response = self.client.delete("/api/v1/roles/test") + response = self.client.delete("/security/v1/roles/test") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.delete( - "/api/v1/roles/test", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/security/v1/roles/test", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 @@ -352,7 +364,7 @@ class TestPatchRole(TestRoleEndpoint): def test_patch_should_respond_200(self, payload, expected_name, expected_actions): role = create_role(self.app, "mytestrole") response = self.client.patch( - f"/api/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"} + f"/security/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 200 assert response.json["name"] == expected_name @@ -363,7 +375,7 @@ def test_patch_should_update_correct_roles_permissions(self): create_role(self.app, "already_exists") response = self.client.patch( - "/api/v1/roles/role_to_change", + "/security/v1/roles/role_to_change", json={ "name": "already_exists", "actions": [{"action": {"name": "can_delete"}, "resource": {"name": "XComs"}}], @@ -408,7 +420,7 @@ def test_patch_should_respond_200_with_update_mask( role = create_role(self.app, "mytestrole") assert role.permissions == [] response = self.client.patch( - f"/api/v1/roles/{role.name}{update_mask}", + f"/security/v1/roles/{role.name}{update_mask}", json=payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -420,7 +432,7 @@ def test_patch_should_respond_400_for_invalid_fields_in_update_mask(self): role = create_role(self.app, "mytestrole") payload = {"name": "testme"} response = self.client.patch( - f"/api/v1/roles/{role.name}?update_mask=invalid_name", + f"/security/v1/roles/{role.name}?update_mask=invalid_name", json=payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -480,7 +492,7 @@ def test_patch_should_respond_400_for_invalid_fields_in_update_mask(self): def test_patch_should_respond_400_for_invalid_update(self, payload, expected_error): role = create_role(self.app, "mytestrole") response = self.client.patch( - f"/api/v1/roles/{role.name}", + f"/security/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -489,7 +501,7 @@ def test_patch_should_respond_400_for_invalid_update(self, payload, expected_err def test_should_raises_401_unauthenticated(self): response = self.client.patch( - "/api/v1/roles/test", + "/security/v1/roles/test", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], @@ -500,7 +512,7 @@ def test_should_raises_401_unauthenticated(self): def test_should_raise_403_forbidden(self): response = self.client.patch( - "/api/v1/roles/test", + "/security/v1/roles/test", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], diff --git a/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py index 44c005f8f85e1..ac20a2d33ae75 100644 --- a/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py +++ b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py @@ -91,7 +91,7 @@ def test_should_respond_200(self): users = self._create_users(1) self.session.add_all(users) self.session.commit() - response = self.client.get("/api/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -119,7 +119,7 @@ def test_last_names_can_be_empty(self): ) self.session.add_all([prince]) self.session.commit() - response = self.client.get("/api/v1/users/prince", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users/prince", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -147,7 +147,7 @@ def test_first_names_can_be_empty(self): ) self.session.add_all([liberace]) self.session.commit() - response = self.client.get("/api/v1/users/liberace", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users/liberace", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -175,7 +175,7 @@ def test_both_first_and_last_names_can_be_empty(self): ) self.session.add_all([nameless]) self.session.commit() - response = self.client.get("/api/v1/users/nameless", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users/nameless", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -192,7 +192,9 @@ def test_both_first_and_last_names_can_be_empty(self): } def test_should_respond_404(self): - response = self.client.get("/api/v1/users/invalid-user", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get( + "/security/v1/users/invalid-user", environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 404 assert { "detail": "The User with username `invalid-user` was not found", @@ -202,30 +204,32 @@ def test_should_respond_404(self): } == response.json def test_should_raises_401_unauthenticated(self): - response = self.client.get("/api/v1/users/TEST_USER1") + response = self.client.get("/security/v1/users/TEST_USER1") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/api/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/security/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 class TestGetUsers(TestUserEndpoint): def test_should_response_200(self): - response = self.client.get("/api/v1/users", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json["total_entries"] == 2 usernames = [user["username"] for user in response.json["users"] if user] assert usernames == ["test", "test_no_permissions"] def test_should_raises_401_unauthenticated(self): - response = self.client.get("/api/v1/users") + response = self.client.get("/security/v1/users") assert_401(response) def test_should_raise_403_forbidden(self): - response = self.client.get("/api/v1/users", environ_overrides={"REMOTE_USER": "test_no_permissions"}) + response = self.client.get( + "/security/v1/users", environ_overrides={"REMOTE_USER": "test_no_permissions"} + ) assert response.status_code == 403 @@ -233,10 +237,10 @@ class TestGetUsersPagination(TestUserEndpoint): @pytest.mark.parametrize( "url, expected_usernames", [ - ("/api/v1/users?limit=1", ["test"]), - ("/api/v1/users?limit=2", ["test", "test_no_permissions"]), + ("/security/v1/users?limit=1", ["test"]), + ("/security/v1/users?limit=2", ["test", "test_no_permissions"]), ( - "/api/v1/users?offset=5", + "/security/v1/users?offset=5", [ "TEST_USER4", "TEST_USER5", @@ -248,7 +252,7 @@ class TestGetUsersPagination(TestUserEndpoint): ], ), ( - "/api/v1/users?offset=0", + "/security/v1/users?offset=0", [ "test", "test_no_permissions", @@ -264,10 +268,10 @@ class TestGetUsersPagination(TestUserEndpoint): "TEST_USER10", ], ), - ("/api/v1/users?limit=1&offset=5", ["TEST_USER4"]), - ("/api/v1/users?limit=1&offset=1", ["test_no_permissions"]), + ("/security/v1/users?limit=1&offset=5", ["TEST_USER4"]), + ("/security/v1/users?limit=1&offset=1", ["test_no_permissions"]), ( - "/api/v1/users?limit=2&offset=2", + "/security/v1/users?limit=2&offset=2", ["TEST_USER1", "TEST_USER2"], ), ], @@ -287,7 +291,7 @@ def test_should_respect_page_size_limit_default(self): self.session.add_all(users) self.session.commit() - response = self.client.get("/api/v1/users", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 # Explicitly add the 2 users on setUp assert response.json["total_entries"] == 200 + len(["test", "test_no_permissions"]) @@ -297,7 +301,9 @@ def test_should_response_400_with_invalid_order_by(self): users = self._create_users(2) self.session.add_all(users) self.session.commit() - response = self.client.get("/api/v1/users?order_by=myname", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get( + "/security/v1/users?order_by=myname", environ_overrides={"REMOTE_USER": "test"} + ) assert response.status_code == 400 msg = "Ordering with 'myname' is disallowed or the attribute does not exist on the model" assert response.json["detail"] == msg @@ -307,7 +313,7 @@ def test_limit_of_zero_should_return_default(self): self.session.add_all(users) self.session.commit() - response = self.client.get("/api/v1/users?limit=0", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users?limit=0", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 # Explicit add the 2 users on setUp assert response.json["total_entries"] == 200 + len(["test", "test_no_permissions"]) @@ -319,7 +325,7 @@ def test_should_return_conf_max_if_req_max_above_conf(self): self.session.add_all(users) self.session.commit() - response = self.client.get("/api/v1/users?limit=180", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/security/v1/users?limit=180", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert len(response.json["users"]) == 150 @@ -411,7 +417,7 @@ def autoclean_admin_user(configured_app, autoclean_user_payload): class TestPostUser(TestUserEndpoint): def test_with_default_role(self, autoclean_username, autoclean_user_payload): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -424,7 +430,7 @@ def test_with_default_role(self, autoclean_username, autoclean_user_payload): def test_with_custom_roles(self, autoclean_username, autoclean_user_payload): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload}, environ_overrides={"REMOTE_USER": "test"}, ) @@ -438,7 +444,7 @@ def test_with_custom_roles(self, autoclean_username, autoclean_user_payload): @pytest.mark.usefixtures("user_different") def test_with_existing_different_user(self, autoclean_user_payload): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload}, environ_overrides={"REMOTE_USER": "test"}, ) @@ -446,14 +452,14 @@ def test_with_existing_different_user(self, autoclean_user_payload): def test_unauthenticated(self, autoclean_user_payload): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json=autoclean_user_payload, ) assert response.status_code == 401, response.json def test_forbidden(self, autoclean_user_payload): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test_no_permissions"}, ) @@ -477,7 +483,7 @@ def test_already_exists( existing = request.getfixturevalue(existing_user_fixture_name) response = self.client.post( - "/api/v1/users", + "/security/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -513,7 +519,7 @@ def test_already_exists( ) def test_invalid_payload(self, autoclean_user_payload, payload_converter, error_message): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json=payload_converter(autoclean_user_payload), environ_overrides={"REMOTE_USER": "test"}, ) @@ -528,7 +534,7 @@ def test_invalid_payload(self, autoclean_user_payload, payload_converter, error_ def test_internal_server_error(self, autoclean_user_payload): with unittest.mock.patch.object(self.app.appbuilder.sm, "add_user", return_value=None): response = self.client.post( - "/api/v1/users", + "/security/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -545,7 +551,7 @@ class TestPatchUser(TestUserEndpoint): def test_change(self, autoclean_username, autoclean_user_payload): autoclean_user_payload["first_name"] = "Changed" response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -561,7 +567,7 @@ def test_change_with_update_mask(self, autoclean_username, autoclean_user_payloa autoclean_user_payload["first_name"] = "Changed" autoclean_user_payload["last_name"] = "McTesterson" response = self.client.patch( - f"/api/v1/users/{autoclean_username}?update_mask=last_name", + f"/security/v1/users/{autoclean_username}?update_mask=last_name", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -591,7 +597,7 @@ def test_patch_already_exists( ): autoclean_user_payload.update(payload) response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -612,7 +618,7 @@ def test_required_fields( ): autoclean_user_payload.pop(field) response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -624,7 +630,7 @@ def test_username_can_be_updated(self, autoclean_user_payload, autoclean_usernam testusername = "testusername" autoclean_user_payload.update({"username": testusername}) response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -644,7 +650,7 @@ def test_password_hashed( ): autoclean_user_payload["password"] = "new-pass" response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -663,7 +669,7 @@ def test_replace_roles(self, autoclean_username, autoclean_user_payload): # Patching a user's roles should replace the entire list. autoclean_user_payload["roles"] = [{"name": "User"}, {"name": "Viewer"}] response = self.client.patch( - f"/api/v1/users/{autoclean_username}?update_mask=roles", + f"/security/v1/users/{autoclean_username}?update_mask=roles", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -674,7 +680,7 @@ def test_replace_roles(self, autoclean_username, autoclean_user_payload): def test_unchanged(self, autoclean_username, autoclean_user_payload): # Should allow a PATCH that changes nothing. response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -686,7 +692,7 @@ def test_unchanged(self, autoclean_username, autoclean_user_payload): @pytest.mark.usefixtures("autoclean_admin_user") def test_unauthenticated(self, autoclean_username, autoclean_user_payload): response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, ) assert response.status_code == 401, response.json @@ -694,7 +700,7 @@ def test_unauthenticated(self, autoclean_username, autoclean_user_payload): @pytest.mark.usefixtures("autoclean_admin_user") def test_forbidden(self, autoclean_username, autoclean_user_payload): response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test_no_permissions"}, ) @@ -703,7 +709,7 @@ def test_forbidden(self, autoclean_username, autoclean_user_payload): def test_not_found(self, autoclean_username, autoclean_user_payload): # This test does not populate autoclean_admin_user into the database. response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -743,7 +749,7 @@ def test_invalid_payload( error_message, ): response = self.client.patch( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", json=payload_converter(autoclean_user_payload), environ_overrides={"REMOTE_USER": "test"}, ) @@ -760,7 +766,7 @@ class TestDeleteUser(TestUserEndpoint): @pytest.mark.usefixtures("autoclean_admin_user") def test_delete(self, autoclean_username): response = self.client.delete( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", environ_overrides={"REMOTE_USER": "test"}, ) assert response.status_code == 204, response.json # NO CONTENT. @@ -769,7 +775,7 @@ def test_delete(self, autoclean_username): @pytest.mark.usefixtures("autoclean_admin_user") def test_unauthenticated(self, autoclean_username): response = self.client.delete( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", ) assert response.status_code == 401, response.json assert self.session.query(count(User.id)).filter(User.username == autoclean_username).scalar() == 1 @@ -777,7 +783,7 @@ def test_unauthenticated(self, autoclean_username): @pytest.mark.usefixtures("autoclean_admin_user") def test_forbidden(self, autoclean_username): response = self.client.delete( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", environ_overrides={"REMOTE_USER": "test_no_permissions"}, ) assert response.status_code == 403, response.json @@ -786,7 +792,7 @@ def test_forbidden(self, autoclean_username): def test_not_found(self, autoclean_username): # This test does not populate autoclean_admin_user into the database. response = self.client.delete( - f"/api/v1/users/{autoclean_username}", + f"/security/v1/users/{autoclean_username}", environ_overrides={"REMOTE_USER": "test"}, ) assert response.status_code == 404, response.json diff --git a/tests/test_utils/decorators.py b/tests/test_utils/decorators.py index 599db286dec2b..9cabbcc0cd9ff 100644 --- a/tests/test_utils/decorators.py +++ b/tests/test_utils/decorators.py @@ -41,6 +41,7 @@ def no_op(*args, **kwargs): "init_api_internal", "init_api_experimental", "init_api_auth_provider", + "init_api_error_handlers", "sync_appbuilder_roles", "init_jinja_globals", "init_xframe_protection", From 32e67aa8004de1097d7406d916213c91678b8c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 12:54:34 -0700 Subject: [PATCH 07/17] apply suggestion --- airflow/auth/managers/base_auth_manager.py | 2 +- airflow/auth/managers/fab/fab_auth_manager.py | 2 +- airflow/www/extensions/init_views.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airflow/auth/managers/base_auth_manager.py b/airflow/auth/managers/base_auth_manager.py index 212095a304076..1fe3695098750 100644 --- a/airflow/auth/managers/base_auth_manager.py +++ b/airflow/auth/managers/base_auth_manager.py @@ -50,7 +50,7 @@ def get_cli_commands() -> list[CLICommand]: """ return [] - def get_blueprint(self) -> None | Blueprint: + def get_api_blueprint(self) -> None | Blueprint: """Return a blueprint of the API endpoints proposed by this auth manager.""" return None diff --git a/airflow/auth/managers/fab/fab_auth_manager.py b/airflow/auth/managers/fab/fab_auth_manager.py index 942e3d39b3159..fc7808ad10288 100644 --- a/airflow/auth/managers/fab/fab_auth_manager.py +++ b/airflow/auth/managers/fab/fab_auth_manager.py @@ -70,7 +70,7 @@ def get_cli_commands() -> list[CLICommand]: SYNC_PERM_COMMAND, # not in a command group ] - def get_blueprint(self) -> None | Blueprint: + def get_api_blueprint(self) -> None | Blueprint: """Return a blueprint of the API endpoints proposed by this auth manager.""" folder = Path(__file__).parents[0].resolve() # this is airflow/auth/managers/fab/ with folder.joinpath("openapi", "v1.yaml").open() as f: diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py index 6aff56607942f..e36ac42d48d0e 100644 --- a/airflow/www/extensions/init_views.py +++ b/airflow/www/extensions/init_views.py @@ -326,7 +326,7 @@ def init_api_experimental(app): def init_api_auth_provider(app): """Initialize the API offered by the authentication provider.""" auth_mgr = get_auth_manager() - blueprint = auth_mgr.get_blueprint() + blueprint = auth_mgr.get_api_blueprint() if blueprint is not None: base_paths.append(blueprint.url_prefix) app.register_blueprint(blueprint) From 8a8c73847e29328388793270a8a7800d6e3ccefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 12:55:19 -0700 Subject: [PATCH 08/17] docstring change Co-authored-by: Vincent <97131062+vincbeck@users.noreply.github.com> --- airflow/www/extensions/init_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py index e36ac42d48d0e..4fa8d24b9477d 100644 --- a/airflow/www/extensions/init_views.py +++ b/airflow/www/extensions/init_views.py @@ -324,7 +324,7 @@ def init_api_experimental(app): def init_api_auth_provider(app): - """Initialize the API offered by the authentication provider.""" + """Initialize the API offered by the auth manager.""" auth_mgr = get_auth_manager() blueprint = auth_mgr.get_api_blueprint() if blueprint is not None: From 8c104aa85f399c924f645b6ddb3060bf1558b2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Wed, 13 Sep 2023 15:36:50 -0700 Subject: [PATCH 09/17] more test fixing --- tests/api_connexion/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api_connexion/conftest.py b/tests/api_connexion/conftest.py index e3865176dd87b..c860a78f27167 100644 --- a/tests/api_connexion/conftest.py +++ b/tests/api_connexion/conftest.py @@ -33,6 +33,7 @@ def minimal_app_for_api(): "init_appbuilder", "init_api_experimental_auth", "init_api_connexion", + "init_api_error_handlers", "init_airflow_session_interface", "init_appbuilder_views", ] From 5ee06cf4a557f61615c1dd2f1e926f51cf2305aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Fri, 22 Sep 2023 09:10:25 -0700 Subject: [PATCH 10/17] change endpoint path to auth/fab --- airflow/auth/managers/fab/fab_auth_manager.py | 2 +- .../test_role_and_permission_endpoint.py | 68 +++++++------- .../fab/api_endpoints/test_user_endpoint.py | 90 +++++++++---------- 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/airflow/auth/managers/fab/fab_auth_manager.py b/airflow/auth/managers/fab/fab_auth_manager.py index fc7808ad10288..a77f9f0bb0067 100644 --- a/airflow/auth/managers/fab/fab_auth_manager.py +++ b/airflow/auth/managers/fab/fab_auth_manager.py @@ -78,7 +78,7 @@ def get_api_blueprint(self) -> None | Blueprint: api = FlaskApi( specification=specification, resolver=_LazyResolver(), - base_path="/security/v1", + base_path="/auth/fab/v1", options={ "swagger_ui": conf.getboolean("webserver", "enable_swagger_ui", fallback=True), }, diff --git a/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py index 7b2e77b50b29e..b8a8d836998ae 100644 --- a/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py +++ b/tests/auth/managers/fab/api_endpoints/test_role_and_permission_endpoint.py @@ -74,13 +74,13 @@ def teardown_method(self): class TestGetRoleEndpoint(TestRoleEndpoint): def test_should_response_200(self): - response = self.client.get("/security/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json["name"] == "Admin" def test_should_respond_404(self): response = self.client.get( - "/security/v1/roles/invalid-role", environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/roles/invalid-role", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 404 assert { @@ -91,19 +91,19 @@ def test_should_respond_404(self): } == response.json def test_should_raises_401_unauthenticated(self): - response = self.client.get("/security/v1/roles/Admin") + response = self.client.get("/auth/fab/v1/roles/Admin") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/security/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/auth/fab/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 class TestGetRolesEndpoint(TestRoleEndpoint): def test_should_response_200(self): - response = self.client.get("/security/v1/roles", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/roles", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 existing_roles = set(EXISTING_ROLES) existing_roles.update(["Test", "TestNoPermissions"]) @@ -112,12 +112,12 @@ def test_should_response_200(self): assert roles == existing_roles def test_should_raises_401_unauthenticated(self): - response = self.client.get("/security/v1/roles") + response = self.client.get("/auth/fab/v1/roles") assert_401(response) def test_should_raises_400_for_invalid_order_by(self): response = self.client.get( - "/security/v1/roles?order_by=invalid", environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/roles?order_by=invalid", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 400 msg = "Ordering with 'invalid' is disallowed or the attribute does not exist on the model" @@ -125,7 +125,7 @@ def test_should_raises_400_for_invalid_order_by(self): def test_should_raise_403_forbidden(self): response = self.client.get( - "/security/v1/roles", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/auth/fab/v1/roles", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 @@ -134,20 +134,20 @@ class TestGetRolesEndpointPaginationandFilter(TestRoleEndpoint): @pytest.mark.parametrize( "url, expected_roles", [ - ("/security/v1/roles?limit=1", ["Admin"]), - ("/security/v1/roles?limit=2", ["Admin", "Op"]), + ("/auth/fab/v1/roles?limit=1", ["Admin"]), + ("/auth/fab/v1/roles?limit=2", ["Admin", "Op"]), ( - "/security/v1/roles?offset=1", + "/auth/fab/v1/roles?offset=1", ["Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], ), ( - "/security/v1/roles?offset=0", + "/auth/fab/v1/roles?offset=0", ["Admin", "Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], ), - ("/security/v1/roles?limit=1&offset=2", ["Public"]), - ("/security/v1/roles?limit=1&offset=1", ["Op"]), + ("/auth/fab/v1/roles?limit=1&offset=2", ["Public"]), + ("/auth/fab/v1/roles?limit=1&offset=1", ["Op"]), ( - "/security/v1/roles?limit=2&offset=2", + "/auth/fab/v1/roles?limit=2&offset=2", ["Public", "Test"], ), ], @@ -165,7 +165,7 @@ def test_can_handle_limit_and_offset(self, url, expected_roles): class TestGetPermissionsEndpoint(TestRoleEndpoint): def test_should_response_200(self): - response = self.client.get("/security/v1/permissions", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/permissions", environ_overrides={"REMOTE_USER": "test"}) actions = {i[0] for i in self.app.appbuilder.sm.get_all_permissions() if i} assert response.status_code == 200 assert response.json["total_entries"] == len(actions) @@ -173,12 +173,12 @@ def test_should_response_200(self): assert actions == returned_actions def test_should_raises_401_unauthenticated(self): - response = self.client.get("/security/v1/permissions") + response = self.client.get("/auth/fab/v1/permissions") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/security/v1/permissions", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/auth/fab/v1/permissions", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 @@ -190,7 +190,7 @@ def test_post_should_respond_200(self): "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], } response = self.client.post( - "/security/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 200 role = self.app.appbuilder.sm.find_role("Test2") @@ -263,7 +263,7 @@ def test_post_should_respond_200(self): ) def test_post_should_respond_400_for_invalid_payload(self, payload, error_message): response = self.client.post( - "/security/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 400 assert response.json == { @@ -279,7 +279,7 @@ def test_post_should_respond_409_already_exist(self): "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], } response = self.client.post( - "/security/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 409 assert response.json == { @@ -291,7 +291,7 @@ def test_post_should_respond_409_already_exist(self): def test_should_raises_401_unauthenticated(self): response = self.client.post( - "/security/v1/roles", + "/auth/fab/v1/roles", json={ "name": "Test2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], @@ -302,7 +302,7 @@ def test_should_raises_401_unauthenticated(self): def test_should_raise_403_forbidden(self): response = self.client.post( - "/security/v1/roles", + "/auth/fab/v1/roles", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], @@ -316,7 +316,7 @@ class TestDeleteRole(TestRoleEndpoint): def test_delete_should_respond_204(self, session): role = create_role(self.app, "mytestrole") response = self.client.delete( - f"/security/v1/roles/{role.name}", environ_overrides={"REMOTE_USER": "test"} + f"/auth/fab/v1/roles/{role.name}", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 204 role_obj = session.query(Role).filter(Role.name == role.name).all() @@ -324,7 +324,7 @@ def test_delete_should_respond_204(self, session): def test_delete_should_respond_404(self): response = self.client.delete( - "/security/v1/roles/invalidrolename", environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/roles/invalidrolename", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 404 assert response.json == { @@ -335,13 +335,13 @@ def test_delete_should_respond_404(self): } def test_should_raises_401_unauthenticated(self): - response = self.client.delete("/security/v1/roles/test") + response = self.client.delete("/auth/fab/v1/roles/test") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.delete( - "/security/v1/roles/test", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/auth/fab/v1/roles/test", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 @@ -364,7 +364,7 @@ class TestPatchRole(TestRoleEndpoint): def test_patch_should_respond_200(self, payload, expected_name, expected_actions): role = create_role(self.app, "mytestrole") response = self.client.patch( - f"/security/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"} + f"/auth/fab/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 200 assert response.json["name"] == expected_name @@ -375,7 +375,7 @@ def test_patch_should_update_correct_roles_permissions(self): create_role(self.app, "already_exists") response = self.client.patch( - "/security/v1/roles/role_to_change", + "/auth/fab/v1/roles/role_to_change", json={ "name": "already_exists", "actions": [{"action": {"name": "can_delete"}, "resource": {"name": "XComs"}}], @@ -420,7 +420,7 @@ def test_patch_should_respond_200_with_update_mask( role = create_role(self.app, "mytestrole") assert role.permissions == [] response = self.client.patch( - f"/security/v1/roles/{role.name}{update_mask}", + f"/auth/fab/v1/roles/{role.name}{update_mask}", json=payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -432,7 +432,7 @@ def test_patch_should_respond_400_for_invalid_fields_in_update_mask(self): role = create_role(self.app, "mytestrole") payload = {"name": "testme"} response = self.client.patch( - f"/security/v1/roles/{role.name}?update_mask=invalid_name", + f"/auth/fab/v1/roles/{role.name}?update_mask=invalid_name", json=payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -492,7 +492,7 @@ def test_patch_should_respond_400_for_invalid_fields_in_update_mask(self): def test_patch_should_respond_400_for_invalid_update(self, payload, expected_error): role = create_role(self.app, "mytestrole") response = self.client.patch( - f"/security/v1/roles/{role.name}", + f"/auth/fab/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -501,7 +501,7 @@ def test_patch_should_respond_400_for_invalid_update(self, payload, expected_err def test_should_raises_401_unauthenticated(self): response = self.client.patch( - "/security/v1/roles/test", + "/auth/fab/v1/roles/test", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], @@ -512,7 +512,7 @@ def test_should_raises_401_unauthenticated(self): def test_should_raise_403_forbidden(self): response = self.client.patch( - "/security/v1/roles/test", + "/auth/fab/v1/roles/test", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], diff --git a/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py index ac20a2d33ae75..51427ddfcbeb0 100644 --- a/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py +++ b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py @@ -91,7 +91,7 @@ def test_should_respond_200(self): users = self._create_users(1) self.session.add_all(users) self.session.commit() - response = self.client.get("/security/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -119,7 +119,7 @@ def test_last_names_can_be_empty(self): ) self.session.add_all([prince]) self.session.commit() - response = self.client.get("/security/v1/users/prince", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users/prince", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -147,7 +147,7 @@ def test_first_names_can_be_empty(self): ) self.session.add_all([liberace]) self.session.commit() - response = self.client.get("/security/v1/users/liberace", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users/liberace", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -175,7 +175,7 @@ def test_both_first_and_last_names_can_be_empty(self): ) self.session.add_all([nameless]) self.session.commit() - response = self.client.get("/security/v1/users/nameless", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users/nameless", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json == { "active": None, @@ -193,7 +193,7 @@ def test_both_first_and_last_names_can_be_empty(self): def test_should_respond_404(self): response = self.client.get( - "/security/v1/users/invalid-user", environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/users/invalid-user", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 404 assert { @@ -204,31 +204,31 @@ def test_should_respond_404(self): } == response.json def test_should_raises_401_unauthenticated(self): - response = self.client.get("/security/v1/users/TEST_USER1") + response = self.client.get("/auth/fab/v1/users/TEST_USER1") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/security/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/auth/fab/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 class TestGetUsers(TestUserEndpoint): def test_should_response_200(self): - response = self.client.get("/security/v1/users", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert response.json["total_entries"] == 2 usernames = [user["username"] for user in response.json["users"] if user] assert usernames == ["test", "test_no_permissions"] def test_should_raises_401_unauthenticated(self): - response = self.client.get("/security/v1/users") + response = self.client.get("/auth/fab/v1/users") assert_401(response) def test_should_raise_403_forbidden(self): response = self.client.get( - "/security/v1/users", environ_overrides={"REMOTE_USER": "test_no_permissions"} + "/auth/fab/v1/users", environ_overrides={"REMOTE_USER": "test_no_permissions"} ) assert response.status_code == 403 @@ -237,10 +237,10 @@ class TestGetUsersPagination(TestUserEndpoint): @pytest.mark.parametrize( "url, expected_usernames", [ - ("/security/v1/users?limit=1", ["test"]), - ("/security/v1/users?limit=2", ["test", "test_no_permissions"]), + ("/auth/fab/v1/users?limit=1", ["test"]), + ("/auth/fab/v1/users?limit=2", ["test", "test_no_permissions"]), ( - "/security/v1/users?offset=5", + "/auth/fab/v1/users?offset=5", [ "TEST_USER4", "TEST_USER5", @@ -252,7 +252,7 @@ class TestGetUsersPagination(TestUserEndpoint): ], ), ( - "/security/v1/users?offset=0", + "/auth/fab/v1/users?offset=0", [ "test", "test_no_permissions", @@ -268,10 +268,10 @@ class TestGetUsersPagination(TestUserEndpoint): "TEST_USER10", ], ), - ("/security/v1/users?limit=1&offset=5", ["TEST_USER4"]), - ("/security/v1/users?limit=1&offset=1", ["test_no_permissions"]), + ("/auth/fab/v1/users?limit=1&offset=5", ["TEST_USER4"]), + ("/auth/fab/v1/users?limit=1&offset=1", ["test_no_permissions"]), ( - "/security/v1/users?limit=2&offset=2", + "/auth/fab/v1/users?limit=2&offset=2", ["TEST_USER1", "TEST_USER2"], ), ], @@ -291,7 +291,7 @@ def test_should_respect_page_size_limit_default(self): self.session.add_all(users) self.session.commit() - response = self.client.get("/security/v1/users", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 # Explicitly add the 2 users on setUp assert response.json["total_entries"] == 200 + len(["test", "test_no_permissions"]) @@ -302,7 +302,7 @@ def test_should_response_400_with_invalid_order_by(self): self.session.add_all(users) self.session.commit() response = self.client.get( - "/security/v1/users?order_by=myname", environ_overrides={"REMOTE_USER": "test"} + "/auth/fab/v1/users?order_by=myname", environ_overrides={"REMOTE_USER": "test"} ) assert response.status_code == 400 msg = "Ordering with 'myname' is disallowed or the attribute does not exist on the model" @@ -313,7 +313,7 @@ def test_limit_of_zero_should_return_default(self): self.session.add_all(users) self.session.commit() - response = self.client.get("/security/v1/users?limit=0", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users?limit=0", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 # Explicit add the 2 users on setUp assert response.json["total_entries"] == 200 + len(["test", "test_no_permissions"]) @@ -325,7 +325,7 @@ def test_should_return_conf_max_if_req_max_above_conf(self): self.session.add_all(users) self.session.commit() - response = self.client.get("/security/v1/users?limit=180", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get("/auth/fab/v1/users?limit=180", environ_overrides={"REMOTE_USER": "test"}) assert response.status_code == 200 assert len(response.json["users"]) == 150 @@ -417,7 +417,7 @@ def autoclean_admin_user(configured_app, autoclean_user_payload): class TestPostUser(TestUserEndpoint): def test_with_default_role(self, autoclean_username, autoclean_user_payload): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -430,7 +430,7 @@ def test_with_default_role(self, autoclean_username, autoclean_user_payload): def test_with_custom_roles(self, autoclean_username, autoclean_user_payload): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload}, environ_overrides={"REMOTE_USER": "test"}, ) @@ -444,7 +444,7 @@ def test_with_custom_roles(self, autoclean_username, autoclean_user_payload): @pytest.mark.usefixtures("user_different") def test_with_existing_different_user(self, autoclean_user_payload): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload}, environ_overrides={"REMOTE_USER": "test"}, ) @@ -452,14 +452,14 @@ def test_with_existing_different_user(self, autoclean_user_payload): def test_unauthenticated(self, autoclean_user_payload): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json=autoclean_user_payload, ) assert response.status_code == 401, response.json def test_forbidden(self, autoclean_user_payload): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test_no_permissions"}, ) @@ -483,7 +483,7 @@ def test_already_exists( existing = request.getfixturevalue(existing_user_fixture_name) response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -519,7 +519,7 @@ def test_already_exists( ) def test_invalid_payload(self, autoclean_user_payload, payload_converter, error_message): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json=payload_converter(autoclean_user_payload), environ_overrides={"REMOTE_USER": "test"}, ) @@ -534,7 +534,7 @@ def test_invalid_payload(self, autoclean_user_payload, payload_converter, error_ def test_internal_server_error(self, autoclean_user_payload): with unittest.mock.patch.object(self.app.appbuilder.sm, "add_user", return_value=None): response = self.client.post( - "/security/v1/users", + "/auth/fab/v1/users", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -551,7 +551,7 @@ class TestPatchUser(TestUserEndpoint): def test_change(self, autoclean_username, autoclean_user_payload): autoclean_user_payload["first_name"] = "Changed" response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -567,7 +567,7 @@ def test_change_with_update_mask(self, autoclean_username, autoclean_user_payloa autoclean_user_payload["first_name"] = "Changed" autoclean_user_payload["last_name"] = "McTesterson" response = self.client.patch( - f"/security/v1/users/{autoclean_username}?update_mask=last_name", + f"/auth/fab/v1/users/{autoclean_username}?update_mask=last_name", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -597,7 +597,7 @@ def test_patch_already_exists( ): autoclean_user_payload.update(payload) response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -618,7 +618,7 @@ def test_required_fields( ): autoclean_user_payload.pop(field) response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -630,7 +630,7 @@ def test_username_can_be_updated(self, autoclean_user_payload, autoclean_usernam testusername = "testusername" autoclean_user_payload.update({"username": testusername}) response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -650,7 +650,7 @@ def test_password_hashed( ): autoclean_user_payload["password"] = "new-pass" response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -669,7 +669,7 @@ def test_replace_roles(self, autoclean_username, autoclean_user_payload): # Patching a user's roles should replace the entire list. autoclean_user_payload["roles"] = [{"name": "User"}, {"name": "Viewer"}] response = self.client.patch( - f"/security/v1/users/{autoclean_username}?update_mask=roles", + f"/auth/fab/v1/users/{autoclean_username}?update_mask=roles", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -680,7 +680,7 @@ def test_replace_roles(self, autoclean_username, autoclean_user_payload): def test_unchanged(self, autoclean_username, autoclean_user_payload): # Should allow a PATCH that changes nothing. response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -692,7 +692,7 @@ def test_unchanged(self, autoclean_username, autoclean_user_payload): @pytest.mark.usefixtures("autoclean_admin_user") def test_unauthenticated(self, autoclean_username, autoclean_user_payload): response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, ) assert response.status_code == 401, response.json @@ -700,7 +700,7 @@ def test_unauthenticated(self, autoclean_username, autoclean_user_payload): @pytest.mark.usefixtures("autoclean_admin_user") def test_forbidden(self, autoclean_username, autoclean_user_payload): response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test_no_permissions"}, ) @@ -709,7 +709,7 @@ def test_forbidden(self, autoclean_username, autoclean_user_payload): def test_not_found(self, autoclean_username, autoclean_user_payload): # This test does not populate autoclean_admin_user into the database. response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -749,7 +749,7 @@ def test_invalid_payload( error_message, ): response = self.client.patch( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", json=payload_converter(autoclean_user_payload), environ_overrides={"REMOTE_USER": "test"}, ) @@ -766,7 +766,7 @@ class TestDeleteUser(TestUserEndpoint): @pytest.mark.usefixtures("autoclean_admin_user") def test_delete(self, autoclean_username): response = self.client.delete( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", environ_overrides={"REMOTE_USER": "test"}, ) assert response.status_code == 204, response.json # NO CONTENT. @@ -775,7 +775,7 @@ def test_delete(self, autoclean_username): @pytest.mark.usefixtures("autoclean_admin_user") def test_unauthenticated(self, autoclean_username): response = self.client.delete( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", ) assert response.status_code == 401, response.json assert self.session.query(count(User.id)).filter(User.username == autoclean_username).scalar() == 1 @@ -783,7 +783,7 @@ def test_unauthenticated(self, autoclean_username): @pytest.mark.usefixtures("autoclean_admin_user") def test_forbidden(self, autoclean_username): response = self.client.delete( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", environ_overrides={"REMOTE_USER": "test_no_permissions"}, ) assert response.status_code == 403, response.json @@ -792,7 +792,7 @@ def test_forbidden(self, autoclean_username): def test_not_found(self, autoclean_username): # This test does not populate autoclean_admin_user into the database. response = self.client.delete( - f"/security/v1/users/{autoclean_username}", + f"/auth/fab/v1/users/{autoclean_username}", environ_overrides={"REMOTE_USER": "test"}, ) assert response.status_code == 404, response.json From 51f803803d2c1d913243394fdf64c523f8c06456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Fri, 22 Sep 2023 10:06:48 -0700 Subject: [PATCH 11/17] add intermediate layer on moved endpoints to check provider --- .../endpoints/forward_to_fab_endpoint.py | 124 ++++++++++++++++++ airflow/api_connexion/openapi/v1.yaml | 22 ++-- 2 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 airflow/api_connexion/endpoints/forward_to_fab_endpoint.py diff --git a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py new file mode 100644 index 0000000000000..100bc1012605a --- /dev/null +++ b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py @@ -0,0 +1,124 @@ +# 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 warnings +from typing import TYPE_CHECKING + +from airflow.api_connexion.exceptions import BadRequest +from airflow.auth.managers.fab.api_endpoints import role_and_permission_endpoint, user_endpoint +from airflow.www.extensions.init_auth_manager import get_auth_manager + +if TYPE_CHECKING: + from typing import Callable + + from airflow.api_connexion.types import APIResponse + + +def _require_fab(func: Callable) -> Callable: + """ + Raise an HTTP error 400 if the provider is not FAB. + + Intended to decorate endpoints that have been migrated from Airflow API to FAB API. + """ + + def inner(*args, **kwargs): + from airflow.auth.managers.fab.fab_auth_manager import FabAuthManager + + auth_mgr = get_auth_manager() + if not isinstance(auth_mgr, FabAuthManager): + raise BadRequest(detail="This endpoint is only available when using the default auth manager.") + else: + warnings.warn( + "This API endpoint is deprecated. " + "Please use the API under /auth/fab/v1 instead for this operation.", + DeprecationWarning, + ) + return func(*args, **kwargs) + + return inner + + +### role + + +@_require_fab +def get_role(**kwargs) -> APIResponse: + """Get role.""" + return role_and_permission_endpoint.get_role(**kwargs) + + +@_require_fab +def get_roles(**kwargs) -> APIResponse: + """Get roles.""" + return role_and_permission_endpoint.get_roles(**kwargs) + + +@_require_fab +def delete_role(**kwargs) -> APIResponse: + """Delete a role.""" + return role_and_permission_endpoint.delete_role(**kwargs) + + +@_require_fab +def patch_role(**kwargs) -> APIResponse: + """Update a role.""" + return role_and_permission_endpoint.patch_role(**kwargs) + + +@_require_fab +def post_role(**kwargs) -> APIResponse: + """Create a new role.""" + return role_and_permission_endpoint.post_role(**kwargs) + + +### permissions +@_require_fab +def get_permissions(**kwargs) -> APIResponse: + """Get permissions.""" + return role_and_permission_endpoint.get_permissions(**kwargs) + + +### user +@_require_fab +def get_user(**kwargs) -> APIResponse: + """Get a user.""" + return user_endpoint.get_user(**kwargs) + + +@_require_fab +def get_users(**kwargs) -> APIResponse: + """Get users.""" + return user_endpoint.get_users(**kwargs) + + +@_require_fab +def post_user(**kwargs) -> APIResponse: + """Create a new user.""" + return user_endpoint.post_user(**kwargs) + + +@_require_fab +def patch_user(**kwargs) -> APIResponse: + """Update a user.""" + return user_endpoint.patch_user(**kwargs) + + +@_require_fab +def delete_user(**kwargs) -> APIResponse: + """Delete a user.""" + return user_endpoint.delete_user(**kwargs) diff --git a/airflow/api_connexion/openapi/v1.yaml b/airflow/api_connexion/openapi/v1.yaml index 7847e0acdb740..7bd8feaf686e6 100644 --- a/airflow/api_connexion/openapi/v1.yaml +++ b/airflow/api_connexion/openapi/v1.yaml @@ -2118,7 +2118,7 @@ paths: Get a list of roles. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_roles tags: [Role] parameters: @@ -2144,7 +2144,7 @@ paths: Create a new role. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: post_role tags: [Role] requestBody: @@ -2178,7 +2178,7 @@ paths: Get a role. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_role tags: [Role] responses: @@ -2202,7 +2202,7 @@ paths: Update a role. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: patch_role tags: [Role] parameters: @@ -2237,7 +2237,7 @@ paths: Delete a role. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: delete_role tags: [Role] responses: @@ -2260,7 +2260,7 @@ paths: Get a list of permissions. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_permissions tags: [Permission] parameters: @@ -2286,7 +2286,7 @@ paths: Get a list of users. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_users tags: [User] parameters: @@ -2312,7 +2312,7 @@ paths: Create a new user with unique username and email. *New in version 2.2.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: post_user tags: [User] requestBody: @@ -2347,7 +2347,7 @@ paths: Get a user with a specific username. *New in version 2.1.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_user tags: [User] responses: @@ -2371,7 +2371,7 @@ paths: Update fields for a user. *New in version 2.2.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: patch_user tags: [User] parameters: @@ -2405,7 +2405,7 @@ paths: Delete a user with a specific username. *New in version 2.2.0* - x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: delete_user tags: [User] responses: From ec46600b1122989eefd9ca52a1f4c095f0f92389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Fri, 22 Sep 2023 15:18:06 -0700 Subject: [PATCH 12/17] a bit more detail in http error Co-authored-by: Vincent <97131062+vincbeck@users.noreply.github.com> --- airflow/api_connexion/endpoints/forward_to_fab_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py index 100bc1012605a..cbca7a88bca19 100644 --- a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py +++ b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py @@ -41,7 +41,7 @@ def inner(*args, **kwargs): auth_mgr = get_auth_manager() if not isinstance(auth_mgr, FabAuthManager): - raise BadRequest(detail="This endpoint is only available when using the default auth manager.") + raise BadRequest(detail="This endpoint is only available when using the default auth manager FabAuthManager.") else: warnings.warn( "This API endpoint is deprecated. " From 8566e295a40221a0fb7b709e3a9cbf13ac90ddaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Vandon?= Date: Mon, 25 Sep 2023 09:35:22 -0700 Subject: [PATCH 13/17] static check fix --- airflow/api_connexion/endpoints/forward_to_fab_endpoint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py index cbca7a88bca19..33aedc209b0de 100644 --- a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py +++ b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py @@ -41,7 +41,9 @@ def inner(*args, **kwargs): auth_mgr = get_auth_manager() if not isinstance(auth_mgr, FabAuthManager): - raise BadRequest(detail="This endpoint is only available when using the default auth manager FabAuthManager.") + raise BadRequest( + detail="This endpoint is only available when using the default auth manager FabAuthManager." + ) else: warnings.warn( "This API endpoint is deprecated. " From df4c63f8a97fbd0a6bd3b91c8d3b3157a238a331 Mon Sep 17 00:00:00 2001 From: Vincent Beck Date: Tue, 26 Sep 2023 15:30:32 -0400 Subject: [PATCH 14/17] Add file `airflow/auth/managers/fab/openapi/v1.yaml` to MANIFEST.in --- MANIFEST.in | 1 + setup.cfg | 1 + 2 files changed, 2 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 983e6c09f4235..5a636212b877a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -32,6 +32,7 @@ exclude airflow/www/yarn.lock exclude airflow/www/*.sh include airflow/alembic.ini include airflow/api_connexion/openapi/v1.yaml +include airflow/auth/managers/fab/openapi/v1.yaml include airflow/git_version include airflow/provider_info.schema.json include airflow/customized_form_field_behaviours.schema.json diff --git a/setup.cfg b/setup.cfg index 26c8cb2e0312a..c2b2b0347bfdc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -168,6 +168,7 @@ airflow= provider_info.schema.json airflow.api_connexion.openapi=*.yaml +airflow.auth.managers.fab.openapi=*.yaml airflow.serialization=*.json airflow.utils= context.pyi From 94ae9a26ffa130f75fcaabff5dcf1185b26deba1 Mon Sep 17 00:00:00 2001 From: Vincent Beck Date: Fri, 6 Oct 2023 13:43:40 -0400 Subject: [PATCH 15/17] Fix comment --- airflow/api_connexion/endpoints/forward_to_fab_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py index 33aedc209b0de..ded340d82a56f 100644 --- a/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py +++ b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py @@ -31,7 +31,7 @@ def _require_fab(func: Callable) -> Callable: """ - Raise an HTTP error 400 if the provider is not FAB. + Raise an HTTP error 400 if the auth manager is not FAB. Intended to decorate endpoints that have been migrated from Airflow API to FAB API. """ From b89f30131442cc202200725a6535783c1f35ab6a Mon Sep 17 00:00:00 2001 From: Vincent Beck Date: Fri, 6 Oct 2023 13:51:22 -0400 Subject: [PATCH 16/17] Fix airflow/auth/managers/fab/openapi/v1.yaml --- airflow/auth/managers/fab/openapi/v1.yaml | 56 +++++++++-------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/airflow/auth/managers/fab/openapi/v1.yaml b/airflow/auth/managers/fab/openapi/v1.yaml index a6d9e65b569e1..2c7239ae2e464 100644 --- a/airflow/auth/managers/fab/openapi/v1.yaml +++ b/airflow/auth/managers/fab/openapi/v1.yaml @@ -36,7 +36,7 @@ paths: description: | Get a list of roles. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: get_roles tags: [Role] @@ -61,7 +61,7 @@ paths: description: | Create a new role. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: post_role tags: [Role] @@ -94,7 +94,7 @@ paths: description: | Get a role. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: get_role tags: [Role] @@ -117,7 +117,7 @@ paths: description: | Update a role. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: patch_role tags: [Role] @@ -151,7 +151,7 @@ paths: description: | Delete a role. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: delete_role tags: [Role] @@ -173,7 +173,7 @@ paths: description: | Get a list of permissions. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.role_and_permission_endpoint operationId: get_permissions tags: [Permission] @@ -198,7 +198,7 @@ paths: description: | Get a list of users. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: get_users tags: [User] @@ -223,7 +223,7 @@ paths: description: | Create a new user with unique username and email. - *New in version 2.2.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: post_user tags: [User] @@ -257,7 +257,7 @@ paths: description: | Get a user with a specific username. - *New in version 2.1.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: get_user tags: [User] @@ -280,7 +280,7 @@ paths: description: | Update fields for a user. - *New in version 2.2.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: patch_user tags: [User] @@ -313,7 +313,7 @@ paths: description: | Delete a user with a specific username. - *New in version 2.2.0* + *New in version 2.8.0* x-openapi-router-controller: airflow.auth.managers.fab.api_endpoints.user_endpoint operationId: delete_user tags: [User] @@ -337,34 +337,26 @@ components: description: | A user object. - *New in version 2.1.0* + *New in version 2.8.0* type: object properties: first_name: type: string description: | The user's first name. - - *Changed in version 2.4.0*: The requirement for this to be non-empty was removed. last_name: type: string description: | The user's last name. - - *Changed in version 2.4.0*: The requirement for this to be non-empty was removed. username: type: string description: | The username. - - *Changed in version 2.2.0*: A minimum character length requirement ('minLength') is added. minLength: 1 email: type: string description: | The user's email. - - *Changed in version 2.2.0*: A minimum character length requirement ('minLength') is added. minLength: 1 active: type: boolean @@ -391,8 +383,6 @@ components: type: array description: | User roles. - - *Changed in version 2.2.0*: Field is no longer read-only. items: type: object properties: @@ -416,7 +406,7 @@ components: description: | A user object with sensitive data. - *New in version 2.1.0* + *New in version 2.8.0* allOf: - $ref: '#/components/schemas/UserCollectionItem' - type: object @@ -430,7 +420,7 @@ components: description: | Collection of users. - *New in version 2.1.0* + *New in version 2.8.0* allOf: - type: object properties: @@ -444,15 +434,13 @@ components: description: | a role item. - *New in version 2.1.0* + *New in version 2.8.0* type: object properties: name: type: string description: | The name of the role - - *Changed in version 2.3.0*: A minimum character length requirement ('minLength') is added. minLength: 1 actions: type: array @@ -463,7 +451,7 @@ components: description: | A collection of roles. - *New in version 2.1.0* + *New in version 2.8.0* type: object allOf: - type: object @@ -478,7 +466,7 @@ components: description: | An action Item. - *New in version 2.1.0* + *New in version 2.8.0* type: object properties: name: @@ -490,7 +478,7 @@ components: description: | A collection of actions. - *New in version 2.1.0* + *New in version 2.8.0* type: object allOf: - type: object @@ -505,7 +493,7 @@ components: description: | A resource on which permissions are granted. - *New in version 2.1.0* + *New in version 2.8.0* type: object properties: name: @@ -517,7 +505,7 @@ components: description: | The Action-Resource item. - *New in version 2.1.0* + *New in version 2.8.0* type: object properties: action: @@ -602,7 +590,7 @@ components: description: | The username of the user. - *New in version 2.1.0* + *New in version 2.8.0* RoleName: in: path name: role_name @@ -621,7 +609,7 @@ components: The name of the field to order the results by. Prefix a field name with `-` to reverse the sort order. - *New in version 2.1.0* + *New in version 2.8.0* UpdateMask: in: query From cc17098939e37453b0b1e5e34ab979c81ec6f7b3 Mon Sep 17 00:00:00 2001 From: Vincent Beck Date: Mon, 16 Oct 2023 13:23:01 -0400 Subject: [PATCH 17/17] Replace `get_api_blueprint` by `get_api_endpoints` --- airflow/auth/managers/base_auth_manager.py | 7 ++++--- airflow/auth/managers/fab/fab_auth_manager.py | 7 ++----- airflow/www/extensions/init_views.py | 5 +++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/airflow/auth/managers/base_auth_manager.py b/airflow/auth/managers/base_auth_manager.py index 1063f0e82ed79..8dc66961380e2 100644 --- a/airflow/auth/managers/base_auth_manager.py +++ b/airflow/auth/managers/base_auth_manager.py @@ -24,7 +24,8 @@ from airflow.utils.log.logging_mixin import LoggingMixin if TYPE_CHECKING: - from flask import Blueprint, Flask + from connexion import FlaskApi + from flask import Flask from airflow.auth.managers.models.base_user import BaseUser from airflow.auth.managers.models.resource_details import ( @@ -57,8 +58,8 @@ def get_cli_commands() -> list[CLICommand]: """ return [] - def get_api_blueprint(self) -> None | Blueprint: - """Return a blueprint of the API endpoints proposed by this auth manager.""" + def get_api_endpoints(self) -> None | FlaskApi: + """Return API endpoint(s) definition for the auth manager.""" return None @abstractmethod diff --git a/airflow/auth/managers/fab/fab_auth_manager.py b/airflow/auth/managers/fab/fab_auth_manager.py index 0db65c27c2f09..3221af6c2cd9a 100644 --- a/airflow/auth/managers/fab/fab_auth_manager.py +++ b/airflow/auth/managers/fab/fab_auth_manager.py @@ -64,7 +64,6 @@ from airflow.www.extensions.init_views import _CustomErrorRequestBodyValidator, _LazyResolver if TYPE_CHECKING: - from flask import Blueprint from airflow.auth.managers.fab.models import User from airflow.auth.managers.models.base_user import BaseUser @@ -115,12 +114,11 @@ def get_cli_commands() -> list[CLICommand]: SYNC_PERM_COMMAND, # not in a command group ] - def get_api_blueprint(self) -> None | Blueprint: - """Return a blueprint of the API endpoints proposed by this auth manager.""" + def get_api_endpoints(self) -> None | FlaskApi: folder = Path(__file__).parents[0].resolve() # this is airflow/auth/managers/fab/ with folder.joinpath("openapi", "v1.yaml").open() as f: specification = safe_load(f) - api = FlaskApi( + return FlaskApi( specification=specification, resolver=_LazyResolver(), base_path="/auth/fab/v1", @@ -131,7 +129,6 @@ def get_api_blueprint(self) -> None | Blueprint: validate_responses=True, validator_map={"body": _CustomErrorRequestBodyValidator}, ) - return api.blueprint def get_user_display_name(self) -> str: """Return the user's display name associated to the user in session.""" diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py index 4fa8d24b9477d..e7ba0b72a1f30 100644 --- a/airflow/www/extensions/init_views.py +++ b/airflow/www/extensions/init_views.py @@ -326,8 +326,9 @@ def init_api_experimental(app): def init_api_auth_provider(app): """Initialize the API offered by the auth manager.""" auth_mgr = get_auth_manager() - blueprint = auth_mgr.get_api_blueprint() - if blueprint is not None: + api = auth_mgr.get_api_endpoints() + if api: + blueprint = api.blueprint base_paths.append(blueprint.url_prefix) app.register_blueprint(blueprint) app.extensions["csrf"].exempt(blueprint)