This is an automated email from the ASF dual-hosted git repository.
ephraimanierobi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/master by this push:
new 24aa3bf Add readonly REST API endpoints for users (#14735)
24aa3bf is described below
commit 24aa3bf02a2f987a68d1ff5579cbb34e945fa92c
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Fri Mar 19 18:17:19 2021 +0100
Add readonly REST API endpoints for users (#14735)
Co-authored-by: Kaxil Naik <[email protected]>
---
airflow/api_connexion/endpoints/user_endpoint.py | 51 +++++
airflow/api_connexion/openapi/v1.yaml | 137 +++++++++++++
airflow/api_connexion/schemas/user_schema.py | 72 +++++++
.../api_connexion/endpoints/test_user_endpoint.py | 225 +++++++++++++++++++++
tests/api_connexion/schemas/test_user_schema.py | 139 +++++++++++++
5 files changed, 624 insertions(+)
diff --git a/airflow/api_connexion/endpoints/user_endpoint.py
b/airflow/api_connexion/endpoints/user_endpoint.py
new file mode 100644
index 0000000..277ad3c
--- /dev/null
+++ b/airflow/api_connexion/endpoints/user_endpoint.py
@@ -0,0 +1,51 @@
+# 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 flask import current_app
+from flask_appbuilder.security.sqla.models import User
+from sqlalchemy import func
+
+from airflow.api_connexion import security
+from airflow.api_connexion.exceptions import NotFound
+from airflow.api_connexion.parameters import check_limit, format_parameters
+from airflow.api_connexion.schemas.user_schema import (
+ UserCollection,
+ user_collection_item_schema,
+ user_collection_schema,
+)
+from airflow.security import permissions
+
+
[email protected]_access([(permissions.ACTION_CAN_SHOW,
permissions.RESOURCE_USER_DB_MODELVIEW)])
+def get_user(username):
+ """Get a user"""
+ ab_security_manager = current_app.appbuilder.sm
+ user = ab_security_manager.find_user(username=username)
+ if not user:
+ raise NotFound(title="User not found", detail=f"The User with username
`{username}` was not found")
+ return user_collection_item_schema.dump(user)
+
+
[email protected]_access([(permissions.ACTION_CAN_LIST,
permissions.RESOURCE_USER_DB_MODELVIEW)])
+@format_parameters({'limit': check_limit})
+def get_users(limit=None, offset=None):
+ """Get users"""
+ appbuilder = current_app.appbuilder
+ session = appbuilder.get_session
+ total_entries = session.query(func.count(User.id)).scalar()
+ users =
session.query(User).order_by(User.id).offset(offset).limit(limit).all()
+
+ return user_collection_schema.dump(UserCollection(users=users,
total_entries=total_entries))
diff --git a/airflow/api_connexion/openapi/v1.yaml
b/airflow/api_connexion/openapi/v1.yaml
index ea03240..8dba880 100644
--- a/airflow/api_connexion/openapi/v1.yaml
+++ b/airflow/api_connexion/openapi/v1.yaml
@@ -1447,11 +1447,140 @@ paths:
'403':
$ref: '#/components/responses/PermissionDenied'
+ /users:
+ get:
+ summary: List users
+ x-openapi-router-controller:
airflow.api_connexion.endpoints.user_endpoint
+ operationId: get_users
+ tags: [User]
+ parameters:
+ - $ref: '#/components/parameters/PageLimit'
+ - $ref: '#/components/parameters/PageOffset'
+ responses:
+ '200':
+ description: Success.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserCollection'
+ '401':
+ $ref: '#/components/responses/Unauthenticated'
+ '403':
+ $ref: '#/components/responses/PermissionDenied'
+
+ /users/{username}:
+ parameters:
+ - $ref: '#/components/parameters/Username'
+ get:
+ summary: Get a user
+ x-openapi-router-controller:
airflow.api_connexion.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'
components:
# Reusable schemas (data models)
schemas:
# Database entities
+ UserCollectionItem:
+ description: >
+ A user object
+ type: object
+ properties:
+ user_id:
+ type: integer
+ description: The user id
+ readOnly: true
+ first_name:
+ type: string
+ description: The user firstname
+ last_name:
+ type: string
+ description: The user lastname
+ username:
+ type: string
+ description: The username
+ email:
+ type: string
+ description: The user's email
+ 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
+ readOnly: 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
+ allOf:
+ - $ref: '#/components/schemas/UserCollectionItem'
+ - type: object
+ properties:
+ password:
+ type: string
+ writeOnly: true
+
+ UserCollection:
+ type: object
+ description: Collection of users.
+ allOf:
+ - type: object
+ properties:
+ users:
+ type: array
+ items:
+ $ref: '#/components/schemas/UserCollectionItem'
+ - $ref: '#/components/schemas/CollectionInfo'
+
ConnectionCollectionItem:
description: >
Connection collection item.
@@ -2805,6 +2934,13 @@ components:
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
RoleName:
in: path
name: role_name
@@ -3159,6 +3295,7 @@ tags:
- name: Plugin
- name: Role
- name: Permission
+ - name: User
externalDocs:
url: https://airflow.apache.org/docs/apache-airflow/stable/
diff --git a/airflow/api_connexion/schemas/user_schema.py
b/airflow/api_connexion/schemas/user_schema.py
new file mode 100644
index 0000000..c78493f
--- /dev/null
+++ b/airflow/api_connexion/schemas/user_schema.py
@@ -0,0 +1,72 @@
+# 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 flask_appbuilder.security.sqla.models import User
+from marshmallow import Schema, fields
+from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field
+
+from airflow.api_connexion.parameters import validate_istimezone
+from airflow.api_connexion.schemas.role_and_permission_schema import RoleSchema
+
+
+class UserCollectionItemSchema(SQLAlchemySchema):
+ """user collection item schema"""
+
+ class Meta:
+ """Meta"""
+
+ model = User
+ dateformat = "iso"
+
+ user_id = auto_field('id', dump_only=True)
+ first_name = auto_field()
+ last_name = auto_field()
+ username = auto_field()
+ active = auto_field(dump_only=True)
+ email = auto_field()
+ last_login = auto_field(dump_only=True)
+ login_count = auto_field(dump_only=True)
+ fail_login_count = auto_field(dump_only=True)
+ roles = fields.List(fields.Nested(RoleSchema, only=('name',)))
+ created_on = auto_field(validate=validate_istimezone, dump_only=True)
+ changed_on = auto_field(validate=validate_istimezone, dump_only=True)
+
+
+class UserSchema(UserCollectionItemSchema):
+ """User schema"""
+
+ password = auto_field(load_only=True)
+
+
+class UserCollection(NamedTuple):
+ """User collection"""
+
+ users: List[User]
+ total_entries: int
+
+
+class UserCollectionSchema(Schema):
+ """User collection schema"""
+
+ users = fields.List(fields.Nested(UserCollectionItemSchema))
+ total_entries = fields.Int()
+
+
+user_collection_item_schema = UserCollectionItemSchema()
+user_schema = UserSchema()
+user_collection_schema = UserCollectionSchema()
diff --git a/tests/api_connexion/endpoints/test_user_endpoint.py
b/tests/api_connexion/endpoints/test_user_endpoint.py
new file mode 100644
index 0000000..b52c54c
--- /dev/null
+++ b/tests/api_connexion/endpoints/test_user_endpoint.py
@@ -0,0 +1,225 @@
+# 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 pytest
+from flask_appbuilder.security.sqla.models import User
+from parameterized import parameterized
+
+from airflow.api_connexion.exceptions import EXCEPTIONS_LINK_MAP
+from airflow.security import permissions
+from airflow.utils import timezone
+from tests.test_utils.api_connexion_utils import assert_401, create_user,
delete_user
+from tests.test_utils.config import conf_vars
+
+DEFAULT_TIME = "2020-06-11T18:00:00+00:00"
+
+
[email protected](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_LIST,
permissions.RESOURCE_USER_DB_MODELVIEW),
+ (permissions.ACTION_CAN_SHOW,
permissions.RESOURCE_USER_DB_MODELVIEW),
+ ],
+ )
+ 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 TestUserEndpoint:
+ @pytest.fixture(autouse=True)
+ def setup_attrs(self, configured_app) -> None:
+ self.app = configured_app
+ self.client = self.app.test_client() # type:ignore
+ self.session = self.app.appbuilder.get_session
+
+ def teardown_method(self) -> None:
+ # Delete users that have our custom default time
+ users = self.session.query(User).filter(User.changed_on ==
timezone.parse(DEFAULT_TIME)).all()
+ for user in users:
+ self.session.delete(user)
+ self.session.commit()
+
+ def _create_users(self, count, roles=None):
+ # create users with defined created_on and changed_on date
+ # for easy testing
+ if roles is None:
+ roles = []
+ return [
+ User(
+ first_name=f'test{i}',
+ last_name=f'test{i}',
+ username=f'TEST_USER{i}',
+ email=f'mytest@test{i}.org',
+ roles=roles or [],
+ created_on=timezone.parse(DEFAULT_TIME),
+ changed_on=timezone.parse(DEFAULT_TIME),
+ )
+ for i in range(1, count + 1)
+ ]
+
+
+class TestGetUser(TestUserEndpoint):
+ 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"})
+ assert response.status_code == 200
+ assert response.json == {
+ 'active': None,
+ 'changed_on': DEFAULT_TIME,
+ 'created_on': DEFAULT_TIME,
+ 'email': '[email protected]',
+ 'fail_login_count': None,
+ 'first_name': 'test1',
+ 'last_login': None,
+ 'last_name': 'test1',
+ 'login_count': None,
+ 'roles': [],
+ 'user_id': users[0].id,
+ 'username': 'TEST_USER1',
+ }
+
+ def test_should_respond_404(self):
+ response = self.client.get("/api/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",
+ 'status': 404,
+ 'title': 'User not found',
+ 'type': EXCEPTIONS_LINK_MAP[404],
+ } == response.json
+
+ def test_should_raises_401_unauthenticated(self):
+ response = self.client.get("/api/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"}
+ )
+ 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"})
+ 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")
+ assert_401(response)
+
+ def test_should_raise_403_forbidden(self):
+ response = self.client.get("/api/v1/users",
environ_overrides={'REMOTE_USER': "test_no_permissions"})
+ assert response.status_code == 403
+
+
+class TestGetUsersPagination(TestUserEndpoint):
+ @parameterized.expand(
+ [
+ ("/api/v1/users?limit=1", ["test"]),
+ ("/api/v1/users?limit=2", ["test", "test_no_permissions"]),
+ (
+ "/api/v1/users?offset=5",
+ [
+ "TEST_USER4",
+ "TEST_USER5",
+ "TEST_USER6",
+ "TEST_USER7",
+ "TEST_USER8",
+ "TEST_USER9",
+ "TEST_USER10",
+ ],
+ ),
+ (
+ "/api/v1/users?offset=0",
+ [
+ "test",
+ "test_no_permissions",
+ "TEST_USER1",
+ "TEST_USER2",
+ "TEST_USER3",
+ "TEST_USER4",
+ "TEST_USER5",
+ "TEST_USER6",
+ "TEST_USER7",
+ "TEST_USER8",
+ "TEST_USER9",
+ "TEST_USER10",
+ ],
+ ),
+ ("/api/v1/users?limit=1&offset=5", ["TEST_USER4"]),
+ ("/api/v1/users?limit=1&offset=1", ["test_no_permissions"]),
+ (
+ "/api/v1/users?limit=2&offset=2",
+ ["TEST_USER1", "TEST_USER2"],
+ ),
+ ]
+ )
+ def test_handle_limit_offset(self, url, expected_usernames):
+ users = self._create_users(10)
+ self.session.add_all(users)
+ self.session.commit()
+ response = self.client.get(url, environ_overrides={'REMOTE_USER':
"test"})
+ assert response.status_code == 200
+ assert response.json["total_entries"] == 12
+ usernames = [user["username"] for user in response.json["users"] if
user]
+ assert usernames == expected_usernames
+
+ def test_should_respect_page_size_limit_default(self):
+ users = self._create_users(200)
+ self.session.add_all(users)
+ self.session.commit()
+
+ response = self.client.get("/api/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'])
+ assert len(response.json["users"]) == 100
+
+ def test_limit_of_zero_should_return_default(self):
+ users = self._create_users(200)
+ self.session.add_all(users)
+ self.session.commit()
+
+ response = self.client.get("/api/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'])
+ assert len(response.json["users"]) == 100
+
+ @conf_vars({("api", "maximum_page_limit"): "150"})
+ def test_should_return_conf_max_if_req_max_above_conf(self):
+ users = self._create_users(200)
+ self.session.add_all(users)
+ self.session.commit()
+
+ response = self.client.get("/api/v1/users?limit=180",
environ_overrides={'REMOTE_USER': "test"})
+ assert response.status_code == 200
+ assert len(response.json['users']) == 150
diff --git a/tests/api_connexion/schemas/test_user_schema.py
b/tests/api_connexion/schemas/test_user_schema.py
new file mode 100644
index 0000000..d11198b
--- /dev/null
+++ b/tests/api_connexion/schemas/test_user_schema.py
@@ -0,0 +1,139 @@
+# 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 pytest
+from flask_appbuilder.security.sqla.models import User
+
+from airflow.api_connexion.schemas.user_schema import
user_collection_item_schema, user_schema
+from airflow.utils import timezone
+from tests.test_utils.api_connexion_utils import create_role, delete_role
+
+TEST_EMAIL = "[email protected]"
+
+DEFAULT_TIME = "2021-01-09T13:59:56.336000+00:00"
+
+
[email protected](scope="module")
+def configured_app(minimal_app_for_api):
+ app = minimal_app_for_api
+ create_role(
+ app,
+ name="TestRole",
+ permissions=[],
+ )
+ yield app
+
+ delete_role(app, 'TestRole') # type:ignore
+
+
+class TestUserBase:
+ @pytest.fixture(autouse=True)
+ def setup_attrs(self, configured_app) -> None:
+ self.app = configured_app
+ self.client = self.app.test_client() # type:ignore
+ self.role = self.app.appbuilder.sm.find_role("TestRole")
+ self.session = self.app.appbuilder.get_session
+
+ def teardown_method(self):
+ user = self.session.query(User).filter(User.email ==
TEST_EMAIL).first()
+ if user:
+ self.session.delete(user)
+ self.session.commit()
+
+
+class TestUserCollectionItemSchema(TestUserBase):
+ def test_serialize(self):
+ user_model = User(
+ first_name="Foo",
+ last_name="Bar",
+ username="test",
+ password="test",
+ email=TEST_EMAIL,
+ roles=[self.role],
+ created_on=timezone.parse(DEFAULT_TIME),
+ changed_on=timezone.parse(DEFAULT_TIME),
+ )
+ self.session.add(user_model)
+ self.session.commit()
+ user = self.session.query(User).filter(User.email ==
TEST_EMAIL).first()
+ deserialized_user = user_collection_item_schema.dump(user)
+ # No password in dump
+ assert deserialized_user == {
+ 'created_on': DEFAULT_TIME,
+ 'email': '[email protected]',
+ 'changed_on': DEFAULT_TIME,
+ 'user_id': user.id,
+ 'active': None,
+ 'last_login': None,
+ 'last_name': 'Bar',
+ 'fail_login_count': None,
+ 'first_name': 'Foo',
+ 'username': 'test',
+ 'login_count': None,
+ 'roles': [{'name': 'TestRole'}],
+ }
+
+
+class TestUserSchema(TestUserBase):
+ def test_serialize(self):
+ user_model = User(
+ first_name="Foo",
+ last_name="Bar",
+ username="test",
+ password="test",
+ email=TEST_EMAIL,
+ created_on=timezone.parse(DEFAULT_TIME),
+ changed_on=timezone.parse(DEFAULT_TIME),
+ )
+ self.session.add(user_model)
+ self.session.commit()
+ user = self.session.query(User).filter(User.email ==
TEST_EMAIL).first()
+ deserialized_user = user_schema.dump(user)
+ # No password in dump
+ assert deserialized_user == {
+ 'roles': [],
+ 'created_on': DEFAULT_TIME,
+ 'email': '[email protected]',
+ 'changed_on': DEFAULT_TIME,
+ 'user_id': user.id,
+ 'active': None,
+ 'last_login': None,
+ 'last_name': 'Bar',
+ 'fail_login_count': None,
+ 'first_name': 'Foo',
+ 'username': 'test',
+ 'login_count': None,
+ }
+
+ def test_deserialize_user(self):
+ user_dump = {
+ 'roles': [{'name': 'TestRole'}],
+ 'email': '[email protected]',
+ 'last_name': 'Bar',
+ 'first_name': 'Foo',
+ 'username': 'test',
+ 'password': 'test', # loads password
+ }
+ result = user_schema.load(user_dump)
+ assert result == {
+ 'roles': [{'name': "TestRole"}],
+ 'email': '[email protected]',
+ 'last_name': 'Bar',
+ 'first_name': 'Foo',
+ 'username': 'test',
+ 'password': 'test', # Password loaded
+ }