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
+        }

Reply via email to