diff --git a/airflow/api_connexion/endpoints/provider_endpoint.py b/airflow/api_connexion/endpoints/provider_endpoint.py
new file mode 100644
index 00000000000000..844dcd301495c6
--- /dev/null
+++ b/airflow/api_connexion/endpoints/provider_endpoint.py
@@ -0,0 +1,47 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import re
+from typing import Dict, List
+
+from airflow.api_connexion import security
+from airflow.api_connexion.schemas.provider_schema import ProviderCollection, provider_collection_schema
+from airflow.providers_manager import ProviderInfo, ProvidersManager
+from airflow.security import permissions
+
+
+def _remove_rst_syntax(value: str) -> str:
+ return re.sub("[`_<>]", "", value.strip(" \n."))
+
+
+def _provider_mapper(provider: ProviderInfo) -> Dict:
+ return {
+ "package_name": provider[1]["package-name"],
+ "description": _remove_rst_syntax(provider[1]["description"]),
+ "version": provider[0],
+ }
+
+
+@security.requires_access([(permissions.ACTION_CAN_READ, permissions.RESOURCE_PROVIDER)])
+def get_providers():
+ """Get providers"""
+ providers_info: List[ProviderInfo] = list(ProvidersManager().providers.values())
+ providers = [_provider_mapper(d) for d in providers_info]
+ total_entries = len(providers)
+ return provider_collection_schema.dump(
+ ProviderCollection(providers=providers, total_entries=total_entries)
+ )
diff --git a/airflow/api_connexion/openapi/v1.yaml b/airflow/api_connexion/openapi/v1.yaml
index 2fd58ea3269558..720411cbe84ba7 100644
--- a/airflow/api_connexion/openapi/v1.yaml
+++ b/airflow/api_connexion/openapi/v1.yaml
@@ -133,6 +133,7 @@ info:
|-|-|
| v2.0 | Initial release |
| v2.0.2 | Added /plugins endpoint |
+ | v2.1 | New providers endpoint |
# Trying the API
@@ -849,6 +850,26 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
+ /providers:
+ get:
+ summary: List providers
+ x-openapi-router-controller: airflow.api_connexion.endpoints.provider_endpoint
+ operationId: get_providers
+ tags: [Provider]
+ responses:
+ '200':
+ description: List of providers.
+ content:
+ application/json:
+ schema:
+ allOf:
+ - $ref: '#/components/schemas/ProviderCollection'
+ - $ref: '#/components/schemas/CollectionInfo'
+ '401':
+ $ref: '#/components/responses/Unauthenticated'
+ '403':
+ $ref: '#/components/responses/PermissionDenied'
+
/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances:
parameters:
- $ref: '#/components/parameters/DAGID'
@@ -2016,6 +2037,29 @@ components:
- $ref: '#/components/schemas/CollectionInfo'
+ Provider:
+ description: The provider
+ type: object
+ properties:
+ package_name:
+ type: string
+ description: The package name of the provider.
+ description:
+ type: string
+ description: The description of the provider.
+ version:
+ type: string
+ description: The version of the provider.
+
+ ProviderCollection:
+ type: object
+ properties:
+ providers:
+ type: array
+ items:
+ $ref: '#/components/schemas/Provider'
+
+
SLAMiss:
type: object
properties:
@@ -3390,6 +3434,7 @@ tags:
- name: ImportError
- name: Monitoring
- name: Pool
+ - name: Provider
- name: TaskInstance
- name: Variable
- name: XCom
diff --git a/airflow/api_connexion/schemas/provider_schema.py b/airflow/api_connexion/schemas/provider_schema.py
new file mode 100644
index 00000000000000..df0ddd5e338217
--- /dev/null
+++ b/airflow/api_connexion/schemas/provider_schema.py
@@ -0,0 +1,46 @@
+# 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 typing import List, NamedTuple
+
+from marshmallow import Schema, fields
+
+
+class ProviderSchema(Schema):
+ """Provider schema"""
+
+ package_name = fields.String(required=True)
+ description = fields.String(required=True)
+ version = fields.String(required=True)
+
+
+class ProviderCollection(NamedTuple):
+ """List of Providers"""
+
+ providers: List[ProviderSchema]
+ total_entries: int
+
+
+class ProviderCollectionSchema(Schema):
+ """Provider Collection schema"""
+
+ providers = fields.List(fields.Nested(ProviderSchema))
+ total_entries = fields.Int()
+
+
+provider_collection_schema = ProviderCollectionSchema()
+provider_schema = ProviderSchema()
diff --git a/airflow/security/permissions.py b/airflow/security/permissions.py
index f4337ebe371408..115f25a5d1af41 100644
--- a/airflow/security/permissions.py
+++ b/airflow/security/permissions.py
@@ -38,6 +38,7 @@
RESOURCE_PERMISSION_VIEW = "Permission Views" # Refers to a Perm <-> View mapping, not an MVC View.
RESOURCE_POOL = "Pools"
RESOURCE_PLUGIN = "Plugins"
+RESOURCE_PROVIDER = "Providers"
RESOURCE_ROLE = "Roles"
RESOURCE_SLA_MISS = "SLA Misses"
RESOURCE_TASK_INSTANCE = "Task Instances"
diff --git a/airflow/www/security.py b/airflow/www/security.py
index d1bf9bc5565dba..0cb7b404abe93b 100644
--- a/airflow/www/security.py
+++ b/airflow/www/security.py
@@ -124,6 +124,7 @@ class AirflowSecurityManager(SecurityManager, LoggingMixin): # pylint: disable=
(permissions.ACTION_CAN_READ, permissions.RESOURCE_POOL),
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_POOL),
(permissions.ACTION_CAN_DELETE, permissions.RESOURCE_POOL),
+ (permissions.ACTION_CAN_READ, permissions.RESOURCE_PROVIDER),
(permissions.ACTION_CAN_CREATE, permissions.RESOURCE_VARIABLE),
(permissions.ACTION_CAN_READ, permissions.RESOURCE_VARIABLE),
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_VARIABLE),
diff --git a/docs/apache-airflow/security/access-control.rst b/docs/apache-airflow/security/access-control.rst
index 38f537e6e9734f..26157256abd5bb 100644
--- a/docs/apache-airflow/security/access-control.rst
+++ b/docs/apache-airflow/security/access-control.rst
@@ -168,6 +168,7 @@ Endpoint
/pools/{pool_name} DELETE Pool.can_delete Op
/pools/{pool_name} GET Pool.can_read Op
/pools/{pool_name} PATCH Pool.can_edit Op
+/providers GET Provider.can_read Op
/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances GET DAGs.can_read, DAG Runs.can_read, Task Instances.can_read Viewer
/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id} GET DAGs.can_read, DAG Runs.can_read, Task Instances.can_read Viewer
/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/links GET DAGs.can_read, DAG Runs.can_read, Task Instances.can_read Viewer
diff --git a/tests/api_connexion/endpoints/test_provider_endpoint.py b/tests/api_connexion/endpoints/test_provider_endpoint.py
new file mode 100644
index 00000000000000..693331e7738f5c
--- /dev/null
+++ b/tests/api_connexion/endpoints/test_provider_endpoint.py
@@ -0,0 +1,123 @@
+# 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 collections import OrderedDict
+from unittest import mock
+
+import pytest
+
+from airflow.security import permissions
+from tests.test_utils.api_connexion_utils import create_user, delete_user
+
+MOCK_PROVIDERS = OrderedDict(
+ [
+ (
+ 'apache-airflow-providers-amazon',
+ (
+ '1.0.0',
+ {
+ 'package-name': 'apache-airflow-providers-amazon',
+ 'name': 'Amazon',
+ 'description': '`Amazon Web Services (AWS) `__.\n',
+ 'versions': ['1.0.0'],
+ },
+ ),
+ ),
+ (
+ 'apache-airflow-providers-apache-cassandra',
+ (
+ '1.0.0',
+ {
+ 'package-name': 'apache-airflow-providers-apache-cassandra',
+ 'name': 'Apache Cassandra',
+ 'description': '`Apache Cassandra `__.\n',
+ 'versions': ['1.0.0'],
+ },
+ ),
+ ),
+ ]
+)
+
+
+@pytest.fixture(scope="module")
+def configured_app(minimal_app_for_api):
+ app = minimal_app_for_api
+ create_user(
+ app, # type: ignore
+ username="test",
+ role_name="Test",
+ permissions=[(permissions.ACTION_CAN_READ, permissions.RESOURCE_PROVIDER)],
+ )
+ create_user(app, username="test_no_permissions", role_name="TestNoPermissions") # type: ignore
+
+ yield app
+
+ delete_user(app, username="test") # type: ignore
+ delete_user(app, username="test_no_permissions") # type: ignore
+
+
+class TestBaseProviderEndpoint:
+ @pytest.fixture(autouse=True)
+ def setup_attrs(self, configured_app) -> None:
+ self.app = configured_app
+ self.client = self.app.test_client() # type:ignore
+
+
+class TestGetProviders(TestBaseProviderEndpoint):
+ @mock.patch(
+ "airflow.providers_manager.ProvidersManager.providers",
+ new_callable=mock.PropertyMock,
+ return_value={},
+ )
+ def test_response_200_empty_list(self, mock_providers):
+ response = self.client.get("/api/v1/providers", environ_overrides={'REMOTE_USER': "test"})
+ assert response.status_code == 200
+ assert response.json == {"providers": [], "total_entries": 0}
+
+ @mock.patch(
+ "airflow.providers_manager.ProvidersManager.providers",
+ new_callable=mock.PropertyMock,
+ return_value=MOCK_PROVIDERS,
+ )
+ def test_response_200(self, mock_providers):
+ response = self.client.get("/api/v1/providers", environ_overrides={'REMOTE_USER': "test"})
+ assert response.status_code == 200
+ assert response.json == {
+ 'providers': [
+ {
+ 'description': 'Amazon Web Services (AWS) https://aws.amazon.com/',
+ 'package_name': 'apache-airflow-providers-amazon',
+ 'version': '1.0.0',
+ },
+ {
+ 'description': 'Apache Cassandra http://cassandra.apache.org/',
+ 'package_name': 'apache-airflow-providers-apache-cassandra',
+ 'version': '1.0.0',
+ },
+ ],
+ 'total_entries': 2,
+ }
+
+ def test_should_raises_401_unauthenticated(self):
+ response = self.client.get("/api/v1/providers")
+ assert response.status_code == 401
+
+ def test_should_raise_403_forbidden(self):
+ response = self.client.get(
+ "/api/v1/providers", environ_overrides={'REMOTE_USER': "test_no_permissions"}
+ )
+ assert response.status_code == 403