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/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..ded340d82a56f --- /dev/null +++ b/airflow/api_connexion/endpoints/forward_to_fab_endpoint.py @@ -0,0 +1,126 @@ +# 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 auth manager 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 FabAuthManager." + ) + 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 d36e4a05a0b86..ebd10e855a679 100644 --- a/airflow/api_connexion/openapi/v1.yaml +++ b/airflow/api_connexion/openapi/v1.yaml @@ -2127,12 +2127,13 @@ paths: /roles: get: + deprecated: true summary: List roles description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_roles tags: [Role] parameters: @@ -2152,12 +2153,13 @@ paths: $ref: '#/components/responses/PermissionDenied' post: + deprecated: true summary: Create a role description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: post_role tags: [Role] requestBody: @@ -2185,12 +2187,13 @@ paths: - $ref: '#/components/parameters/RoleName' get: + deprecated: true summary: Get a role description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_role tags: [Role] responses: @@ -2208,12 +2211,13 @@ paths: $ref: '#/components/responses/NotFound' patch: + deprecated: true summary: Update a role description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: patch_role tags: [Role] parameters: @@ -2242,12 +2246,13 @@ paths: $ref: '#/components/responses/NotFound' delete: + deprecated: true summary: Delete a role description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: delete_role tags: [Role] responses: @@ -2264,12 +2269,13 @@ paths: /permissions: get: + deprecated: true summary: List permissions description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_permissions tags: [Permission] parameters: @@ -2289,12 +2295,13 @@ paths: /users: get: + deprecated: true summary: List users description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_users tags: [User] parameters: @@ -2314,12 +2321,13 @@ paths: $ref: '#/components/responses/PermissionDenied' post: + deprecated: true 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.api_connexion.endpoints.user_endpoint + x-openapi-router-controller: airflow.api_connexion.endpoints.forward_to_fab_endpoint operationId: post_user tags: [User] requestBody: @@ -2348,12 +2356,13 @@ paths: parameters: - $ref: '#/components/parameters/Username' get: + deprecated: true summary: Get a user description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: get_user tags: [User] responses: @@ -2371,12 +2380,13 @@ paths: $ref: '#/components/responses/NotFound' patch: + deprecated: true summary: Update a user description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: patch_user tags: [User] parameters: @@ -2404,12 +2414,13 @@ paths: $ref: '#/components/responses/NotFound' delete: + deprecated: true summary: Delete a user description: | 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.api_connexion.endpoints.forward_to_fab_endpoint operationId: delete_user tags: [User] responses: diff --git a/airflow/auth/managers/base_auth_manager.py b/airflow/auth/managers/base_auth_manager.py index 07003380693c0..2a5b1312a0caa 100644 --- a/airflow/auth/managers/base_auth_manager.py +++ b/airflow/auth/managers/base_auth_manager.py @@ -28,6 +28,7 @@ from airflow.utils.session import NEW_SESSION, provide_session if TYPE_CHECKING: + from connexion import FlaskApi from flask import Flask from sqlalchemy.orm import Session @@ -66,6 +67,10 @@ def get_cli_commands() -> list[CLICommand]: """ return [] + def get_api_endpoints(self) -> None | FlaskApi: + """Return API endpoint(s) definition for the auth manager.""" + return None + @abstractmethod def get_user_name(self) -> str: """Return the username associated to the user in session.""" 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 diff --git a/airflow/auth/managers/fab/fab_auth_manager.py b/airflow/auth/managers/fab/fab_auth_manager.py index 6c942babbfc61..1b98c335326f0 100644 --- a/airflow/auth/managers/fab/fab_auth_manager.py +++ b/airflow/auth/managers/fab/fab_auth_manager.py @@ -18,8 +18,10 @@ from __future__ import annotations import warnings +from pathlib import Path from typing import TYPE_CHECKING, Container +from connexion import FlaskApi from flask import url_for from sqlalchemy import select from sqlalchemy.orm import Session, joinedload @@ -43,6 +45,7 @@ from airflow.cli.cli_config import ( GroupCommand, ) +from airflow.configuration import conf from airflow.exceptions import AirflowException from airflow.models import DagModel from airflow.security import permissions @@ -75,9 +78,10 @@ RESOURCE_XCOM, ) from airflow.utils.session import NEW_SESSION, provide_session +from airflow.utils.yaml import safe_load +from airflow.www.extensions.init_views import _CustomErrorRequestBodyValidator, _LazyResolver if TYPE_CHECKING: - from airflow.auth.managers.models.base_user import BaseUser from airflow.cli.cli_config import ( CLICommand, @@ -133,6 +137,22 @@ def get_cli_commands() -> list[CLICommand]: SYNC_PERM_COMMAND, # not in a command group ] + 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) + return FlaskApi( + specification=specification, + resolver=_LazyResolver(), + base_path="/auth/fab/v1", + options={ + "swagger_ui": conf.getboolean("webserver", "enable_swagger_ui", fallback=True), + }, + strict_validation=True, + validate_responses=True, + validator_map={"body": _CustomErrorRequestBodyValidator}, + ) + def get_user_display_name(self) -> str: """Return the user's display name associated to the user in session.""" user = self.get_user() diff --git a/airflow/auth/managers/fab/openapi/v1.yaml b/airflow/auth/managers/fab/openapi/v1.yaml new file mode 100644 index 0000000000000..2c7239ae2e464 --- /dev/null +++ b/airflow/auth/managers/fab/openapi/v1.yaml @@ -0,0 +1,700 @@ +# 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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.0* + type: object + properties: + first_name: + type: string + description: | + The user's first name. + last_name: + type: string + description: | + The user's last name. + username: + type: string + description: | + The username. + minLength: 1 + email: + type: string + description: | + The user's email. + 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. + 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.8.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.8.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.8.0* + type: object + properties: + name: + type: string + description: | + The name of the role + minLength: 1 + actions: + type: array + items: + $ref: '#/components/schemas/ActionResource' + + RoleCollection: + description: | + A collection of roles. + + *New in version 2.8.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.8.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.8.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.8.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.8.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.8.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.8.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 diff --git a/airflow/www/app.py b/airflow/www/app.py index e8da104d2237e..ac6b87e79d6b2 100644 --- a/airflow/www/app.py +++ b/airflow/www/app.py @@ -48,7 +48,9 @@ ) 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_error_handlers, init_api_experimental, init_api_internal, init_appbuilder_views, @@ -169,6 +171,8 @@ 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) + 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 bb3c04608132a..e7ba0b72a1f30 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 @@ -230,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 @@ -249,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( @@ -271,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) @@ -280,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( @@ -308,5 +318,17 @@ 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) + + +def init_api_auth_provider(app): + """Initialize the API offered by the auth manager.""" + auth_mgr = get_auth_manager() + 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) diff --git a/setup.cfg b/setup.cfg index 09b400f6d8e79..b4adb9267c540 100644 --- a/setup.cfg +++ b/setup.cfg @@ -172,6 +172,7 @@ airflow= provider_info.schema.json airflow.api_connexion.openapi=*.yaml +airflow.auth.managers.fab.openapi=*.yaml airflow.serialization=*.json airflow.utils= context.pyi 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", ] 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..66707ef53d8e5 --- /dev/null +++ b/tests/auth/managers/fab/api_endpoints/conftest.py @@ -0,0 +1,40 @@ +# 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_experimental_auth", + "init_api_auth_provider", + "init_api_error_handlers", + ] + ) + 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 86% 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..b8a8d836998ae 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", @@ -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("/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("/api/v1/roles/invalid-role", environ_overrides={"REMOTE_USER": "test"}) + response = self.client.get( + "/auth/fab/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("/auth/fab/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"} + "/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("/api/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"]) @@ -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("/auth/fab/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"} + "/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" 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( + "/auth/fab/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"]), + ("/auth/fab/v1/roles?limit=1", ["Admin"]), + ("/auth/fab/v1/roles?limit=2", ["Admin", "Op"]), ( - "/api/v1/roles?offset=1", + "/auth/fab/v1/roles?offset=1", ["Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], ), ( - "/api/v1/roles?offset=0", + "/auth/fab/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"]), + ("/auth/fab/v1/roles?limit=1&offset=2", ["Public"]), + ("/auth/fab/v1/roles?limit=1&offset=1", ["Op"]), ( - "/api/v1/roles?limit=2&offset=2", + "/auth/fab/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("/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) @@ -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("/auth/fab/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"} + "/auth/fab/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( + "/auth/fab/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( + "/auth/fab/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( + "/auth/fab/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", + "/auth/fab/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", + "/auth/fab/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"/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() 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"} + "/auth/fab/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("/auth/fab/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"} + "/auth/fab/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"/auth/fab/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", + "/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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", + "/auth/fab/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", + "/auth/fab/v1/roles/test", json={ "name": "mytest2", "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], diff --git a/tests/api_connexion/endpoints/test_user_endpoint.py b/tests/auth/managers/fab/api_endpoints/test_user_endpoint.py similarity index 89% rename from tests/api_connexion/endpoints/test_user_endpoint.py rename to tests/auth/managers/fab/api_endpoints/test_user_endpoint.py index ac0c48f689212..51427ddfcbeb0 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", @@ -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("/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("/api/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("/api/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("/api/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, @@ -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( + "/auth/fab/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("/auth/fab/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"} + "/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("/api/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("/api/v1/users") + response = self.client.get("/auth/fab/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( + "/auth/fab/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"]), + ("/auth/fab/v1/users?limit=1", ["test"]), + ("/auth/fab/v1/users?limit=2", ["test", "test_no_permissions"]), ( - "/api/v1/users?offset=5", + "/auth/fab/v1/users?offset=5", [ "TEST_USER4", "TEST_USER5", @@ -248,7 +252,7 @@ class TestGetUsersPagination(TestUserEndpoint): ], ), ( - "/api/v1/users?offset=0", + "/auth/fab/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"]), + ("/auth/fab/v1/users?limit=1&offset=5", ["TEST_USER4"]), + ("/auth/fab/v1/users?limit=1&offset=1", ["test_no_permissions"]), ( - "/api/v1/users?limit=2&offset=2", + "/auth/fab/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("/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"]) @@ -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( + "/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" 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("/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"]) @@ -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("/auth/fab/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", + "/auth/fab/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", + "/auth/fab/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", + "/auth/fab/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", + "/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( - "/api/v1/users", + "/auth/fab/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", + "/auth/fab/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", + "/auth/fab/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", + "/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/v1/users/{autoclean_username}", json=autoclean_user_payload, environ_overrides={"REMOTE_USER": "test"}, ) @@ -633,7 +639,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( @@ -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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/auth/fab/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"/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 @@ -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"/auth/fab/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"/auth/fab/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 522f80a25436d..9cabbcc0cd9ff 100644 --- a/tests/test_utils/decorators.py +++ b/tests/test_utils/decorators.py @@ -40,6 +40,8 @@ def no_op(*args, **kwargs): "init_api_connexion", "init_api_internal", "init_api_experimental", + "init_api_auth_provider", + "init_api_error_handlers", "sync_appbuilder_roles", "init_jinja_globals", "init_xframe_protection",