Files
superset2/tests/unit_tests/commands/databases/sync_permissions_identity_test.py
T

141 lines
5.8 KiB
Python

# 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.
"""
Traces how ``sync_database_permissions_task`` binds an acting identity.
The Celery task receives the immutable ``id`` of the user who enqueued it
(captured at enqueue time by ``SyncPermissionsCommand.validate``), not a
mutable username string. At execution time it resolves that id to a user
record via ``security_manager.get_user_by_id`` and binds the result to
``flask.g.user`` for the duration of the sync. Because resolution is by id,
a username change between enqueue and execution has no effect on which user
record the task acts as.
That identity is not just used for logging: ``Database._get_sqla_engine``
reads ``g.user.id`` to look up a per-user OAuth2 access token, and, for
databases with ``impersonate_user`` enabled, ``Database.get_effective_user``
reads ``g.user.username`` (via ``get_username()``) to pick the identity the
outgoing connection impersonates at the external database. These tests pin
down both halves of that chain: the id-based resolution in the task, and the
fact that the resolved user is what a privileged, identity-sensitive
codepath consumes downstream.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from flask import g
from pytest_mock import MockerFixture
from superset.commands.database.sync_permissions import (
sync_database_permissions_task,
)
from superset.models.core import Database
def test_task_binds_g_user_to_whoever_held_the_id_at_enqueue_time(
mocker: MockerFixture,
) -> None:
"""
The task resolves its acting identity from the user id captured at
enqueue time, via ``security_manager.get_user_by_id``. A username change
that happens between enqueue and execution has no effect on which user
record the task binds ``g.user`` to, because the id -- not the mutable
username -- is what crosses the enqueue/execute boundary.
"""
# Whoever enqueued the task saw this identity at enqueue time. Its id is
# what's passed to the task.
enqueuing_user = MagicMock()
enqueuing_user.id = 101
enqueuing_user.username = "alice"
get_user_mock = mocker.patch(
"superset.commands.database.sync_permissions.security_manager.get_user_by_id",
return_value=enqueuing_user,
)
mock_db_connection = MagicMock()
mocker.patch(
"superset.commands.database.sync_permissions.DatabaseDAO.find_by_id",
return_value=mock_db_connection,
)
observed_g_user: list[MagicMock] = []
def capture_g_user(self: object) -> None:
# Read g.user at the moment the sync logic actually runs, the same
# way privileged downstream code (e.g. _get_sqla_engine) would.
observed_g_user.append(g.user)
mocker.patch(
"superset.commands.database.sync_permissions.SyncPermissionsCommand"
".sync_database_permissions",
autospec=True,
side_effect=capture_g_user,
)
# By the time the task executes, "alice" has been renamed (and the
# username could even have been reassigned to someone else) -- but the
# task was enqueued with id 101, so the rename doesn't affect resolution.
enqueuing_user.username = "alice_renamed"
sync_database_permissions_task(1, 101, "old_db_name")
# Resolution happened purely off the immutable id...
get_user_mock.assert_called_once_with(101)
# ...and the sync ran under the same user captured at enqueue time,
# regardless of the username change in between.
assert observed_g_user == [enqueuing_user]
assert observed_g_user[0].id == 101
def test_g_user_bound_by_the_task_drives_external_db_impersonation_identity(
mocker: MockerFixture,
) -> None:
"""
``Database.get_effective_user`` -- consulted by ``_get_sqla_engine`` to
decide which identity an outgoing, ``impersonate_user``-enabled
connection impersonates at the external database -- reads
``g.user.username``. Whatever user object the task bound to ``g.user``
(per the previous test, the user resolved from the id captured at
enqueue time) is therefore the identity used to connect to the external
database.
"""
database = MagicMock(spec=Database)
database.impersonate_user = True
object_url = MagicMock()
object_url.username = "url-embedded-user"
# ``get_effective_user`` calls ``get_username()``, which reads
# ``g.user.username`` using the ``g`` imported into
# ``superset.utils.core`` (where ``get_username`` is defined) -- patch
# that module's ``g``, matching what the running task actually touches.
user_a = MagicMock()
user_a.username = "user_a"
mocker.patch("superset.utils.core.g", MagicMock(user=user_a))
assert Database.get_effective_user(database, object_url) == "user_a"
# A different user bound to g.user (as would happen if a different id
# had been captured at enqueue time) changes the impersonated identity
# for the exact same database configuration and target URL.
user_b = MagicMock()
user_b.username = "user_b"
mocker.patch("superset.utils.core.g", MagicMock(user=user_b))
assert Database.get_effective_user(database, object_url) == "user_b"