refactor(api): reuse web app access queries for permissions (#40984)

This commit is contained in:
非法操作
2026-08-20 02:27:49 +00:00
committed by GitHub
parent 98c2ffeec7
commit ee997d00fa
8 changed files with 288 additions and 36 deletions
+18 -23
View File
@@ -7,33 +7,29 @@ from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.errors import InvalidArgumentError
from controllers.common.fields import AccessModeResponse, Parameters
from controllers.common.fields import AccessModeResponse, BooleanResultResponse, Parameters
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
WebAppAccessServiceUnavailableError,
WebAppAuthRequiredError,
WebAppNotFoundError,
)
from controllers.web.wraps import WebApiResource
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, EndUser
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.webapp_access_query_service import (
WebAppAccessAppNotFoundError,
WebAppAccessReferenceRequiredError,
WebAppAccessUnavailableError,
)
from services.webapp_auth_service import WebAppAuthService
logger = logging.getLogger(__name__)
@@ -64,7 +60,7 @@ register_response_schema_models(
Parameters,
AppMetaResponse,
AccessModeResponse,
fields.BooleanResultResponse,
BooleanResultResponse,
)
@@ -165,21 +161,23 @@ class AppWebAuthPermission(Resource):
400: "Bad Request",
401: "Unauthorized",
500: "Internal Server Error",
503: "Web App Access Service Unavailable",
}
)
@web_ns.response(200, "Success", web_ns.models[fields.BooleanResultResponse.__name__])
@web_ns.response(200, "Success", web_ns.models[BooleanResultResponse.__name__])
def get(self):
user_id = "visitor"
app_code = request.headers.get(HEADER_NAME_APP_CODE)
app_id = request.args.get("appId")
if not app_id or not app_code:
raise ValueError("appId must be provided")
require_permission_check = WebAppAuthService.is_app_require_permission_check(
app_id=app_id, session=db.session()
)
if not require_permission_check:
return {"result": True}
webapp_access = application_services().webapp_access
try:
requires_permission_check = webapp_access.requires_permission_check(app_id)
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
if not requires_permission_check:
return dump_response(BooleanResultResponse, {"result": True})
try:
tk = extract_webapp_passport(app_code, request)
@@ -188,16 +186,13 @@ class AppWebAuthPermission(Resource):
decoded = PassportService().verify(tk)
user_id = decoded.get("user_id", "visitor")
except Unauthorized:
raise
raise WebAppAuthRequiredError() from None
except Exception:
logger.exception("Unexpected error during auth verification")
raise
features = FeatureService.get_system_features()
if not features.webapp_auth.enabled:
return {"result": True}
res = True
if WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session()):
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(str(user_id), app_id)
return {"result": res}
try:
is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id)
except WebAppAccessUnavailableError:
raise WebAppAccessServiceUnavailableError() from None
return dump_response(BooleanResultResponse, {"result": is_allowed})
@@ -69,6 +69,13 @@ def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
raise WebAppAccessUnavailableError from e
def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
try:
return EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id)
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError) as e:
raise WebAppAccessUnavailableError from e
@dataclass(frozen=True, slots=True)
class ApplicationServices:
account_activation: AccountActivationService
@@ -120,6 +127,7 @@ def build_application_services(
access=WebAppAccessQueryRepository(session_factory=database_client),
webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
access_mode_for_app=_get_enterprise_webapp_access_mode,
is_user_allowed_for_app=_is_user_allowed_to_access_webapp,
),
explore_banner_queries=ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(client=database_client),
+1
View File
@@ -847,6 +847,7 @@ Check if user has permission to access a web application.
| 400 | Bad Request | |
| 401 | Unauthorized | |
| 500 | Internal Server Error | |
| 503 | Web App Access Service Unavailable | |
### [POST] /workflows/run
**Run workflow**
@@ -5,6 +5,8 @@ from typing import Protocol
from enums import WebAppAccessMode
_PERMISSION_CHECK_MODES = frozenset({WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL})
class WebAppAccessQuery(Protocol):
def find_app_id_by_code(self, app_code: str) -> str | None: ...
@@ -29,10 +31,12 @@ class WebAppAccessQueryService:
access: WebAppAccessQuery,
webapp_auth_enabled: bool,
access_mode_for_app: Callable[[str], WebAppAccessMode],
is_user_allowed_for_app: Callable[[str, str], bool],
) -> None:
self._access = access
self._webapp_auth_enabled = webapp_auth_enabled
self._access_mode_for_app = access_mode_for_app
self._is_user_allowed_for_app = is_user_allowed_for_app
def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode:
if not self._webapp_auth_enabled:
@@ -47,3 +51,12 @@ class WebAppAccessQueryService:
raise WebAppAccessReferenceRequiredError("appId or appCode must be provided")
return self._access_mode_for_app(app_id)
def requires_permission_check(self, app_id: str) -> bool:
return self._access_mode_for_app(app_id) in _PERMISSION_CHECK_MODES
def is_user_allowed(self, *, user_id: str, app_id: str) -> bool:
if not self._webapp_auth_enabled:
return True
return self._is_user_allowed_for_app(user_id, app_id)
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import Unauthorized
from controllers.common.errors import InvalidArgumentError
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
@@ -14,6 +15,7 @@ from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
WebAppAccessServiceUnavailableError,
WebAppAuthRequiredError,
WebAppNotFoundError,
)
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
@@ -194,14 +196,151 @@ class TestAppAccessMode:
# AppWebAuthPermission
# ---------------------------------------------------------------------------
class TestAppWebAuthPermission:
@patch("controllers.web.app.WebAppAuthService.is_app_require_permission_check", return_value=False)
def test_returns_true_when_no_permission_check_required(self, mock_check: MagicMock, app: Flask) -> None:
with app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}):
@patch("controllers.web.app.application_services")
def test_returns_true_without_reading_passport_when_no_permission_check_required(
self, application_services: MagicMock, app: Flask
) -> None:
webapp_access = MagicMock()
webapp_access.requires_permission_check.return_value = False
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport") as extract_passport,
):
result = AppWebAuthPermission().get()
assert result == {"result": True}
webapp_access.requires_permission_check.assert_called_once_with("app-1")
webapp_access.is_user_allowed.assert_not_called()
extract_passport.assert_not_called()
def test_raises_when_missing_app_id(self, app: Flask) -> None:
with app.test_request_context("/webapp/permission", headers={"X-App-Code": "code1"}):
@pytest.mark.parametrize(
("decoded", "expected_user_id", "allowed"),
[
pytest.param({"user_id": "user-1"}, "user-1", True, id="identified-user"),
pytest.param({}, "visitor", False, id="visitor-fallback"),
],
)
@patch("controllers.web.app.application_services")
def test_checks_private_app_permission(
self,
application_services: MagicMock,
decoded: dict[str, str],
expected_user_id: str,
allowed: bool,
app: Flask,
) -> None:
webapp_access = MagicMock()
webapp_access.requires_permission_check.return_value = True
webapp_access.is_user_allowed.return_value = allowed
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport", return_value="passport") as extract_passport,
patch("controllers.web.app.PassportService") as passport_service,
):
passport_service.return_value.verify.return_value = decoded
result = AppWebAuthPermission().get()
assert result == {"result": allowed}
webapp_access.requires_permission_check.assert_called_once_with("app-1")
extract_passport.assert_called_once()
passport_service.return_value.verify.assert_called_once_with("passport")
webapp_access.is_user_allowed.assert_called_once_with(user_id=expected_user_id, app_id="app-1")
@pytest.mark.parametrize("failing_method", ["requires_permission_check", "is_user_allowed"])
@patch("controllers.web.app.application_services")
def test_maps_access_dependency_failure_to_service_unavailable(
self, application_services: MagicMock, failing_method: str, app: Flask
) -> None:
webapp_access = MagicMock()
webapp_access.requires_permission_check.return_value = True
if failing_method == "requires_permission_check":
webapp_access.requires_permission_check.side_effect = WebAppAccessUnavailableError()
else:
webapp_access.is_user_allowed.side_effect = WebAppAccessUnavailableError()
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
passport_service = MagicMock()
passport_service.return_value.verify.return_value = {"user_id": "user-1"}
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport", return_value="passport"),
patch("controllers.web.app.PassportService", passport_service),
pytest.raises(WebAppAccessServiceUnavailableError) as raised,
):
AppWebAuthPermission().get()
assert raised.value.data == {
"code": "web_app_access_unavailable",
"message": "Web app access service is unavailable.",
"status": 503,
}
@patch("controllers.web.app.application_services")
def test_private_app_requires_passport(self, application_services: MagicMock, app: Flask) -> None:
webapp_access = MagicMock()
webapp_access.requires_permission_check.return_value = True
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport", return_value=None),
pytest.raises(WebAppAuthRequiredError) as raised,
):
AppWebAuthPermission().get()
assert raised.value.data == {
"code": "web_sso_auth_required",
"message": "Web app authentication required.",
"status": 401,
}
webapp_access.is_user_allowed.assert_not_called()
@pytest.mark.parametrize(
"description",
["Token has expired.", "Invalid token signature.", "Invalid token."],
)
@patch("controllers.web.app.application_services")
def test_invalid_passport_is_normalized_to_web_app_auth_required(
self, application_services: MagicMock, description: str, app: Flask
) -> None:
webapp_access = MagicMock()
webapp_access.requires_permission_check.return_value = True
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
invalid_passport = Unauthorized(description)
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport", return_value="passport"),
patch("controllers.web.app.PassportService") as passport_service,
):
passport_service.return_value.verify.side_effect = invalid_passport
with pytest.raises(WebAppAuthRequiredError) as raised:
AppWebAuthPermission().get()
assert raised.value.data == {
"code": "web_sso_auth_required",
"message": "Web app authentication required.",
"status": 401,
}
webapp_access.is_user_allowed.assert_not_called()
@pytest.mark.parametrize(
("path", "headers"),
[
pytest.param("/webapp/permission", {"X-App-Code": "code1"}, id="missing-app-id"),
pytest.param("/webapp/permission?appId=app-1", {}, id="missing-app-code"),
],
)
@patch("controllers.web.app.application_services")
def test_raises_when_app_reference_is_missing(
self, application_services: MagicMock, path: str, headers: dict[str, str], app: Flask
) -> None:
with app.test_request_context(path, headers=headers):
with pytest.raises(ValueError, match="appId"):
AppWebAuthPermission().get()
application_services.assert_not_called()
@@ -300,3 +300,49 @@ def test_build_application_services_does_not_hide_unknown_enterprise_errors(
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
assert raised.value is failure
def test_build_application_services_wires_webapp_permission(
sqlite_session_factory: sessionmaker[Session],
) -> None:
with (
patch(
"extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True
) as enabled,
patch(
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
return_value=SimpleNamespace(access_mode="private"),
) as get_access_mode,
patch(
"extensions.ext_application_services.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp",
return_value=False,
) as is_user_allowed,
):
services = ext_application_services.build_application_services(
database_client=sqlite_session_factory,
deployment_edition=DeploymentEdition.COMMUNITY,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
requires_permission = services.webapp_access.requires_permission_check("app-1")
allowed = services.webapp_access.is_user_allowed(user_id="user-1", app_id="app-1")
assert requires_permission is True
assert allowed is False
enabled.assert_called_once_with()
get_access_mode.assert_called_once_with("app-1")
is_user_allowed.assert_called_once_with("user-1", "app-1")
def test_webapp_permission_adapter_maps_connection_failure() -> None:
failure = httpx.ConnectError("connection failed")
with (
patch(
"extensions.ext_application_services.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp",
side_effect=failure,
),
pytest.raises(WebAppAccessUnavailableError) as raised,
):
ext_application_services._is_user_allowed_to_access_webapp("user-1", "app-1")
assert raised.value.__cause__ is failure
@@ -16,21 +16,25 @@ def _service(
access: MagicMock,
enabled: bool = True,
access_mode: WebAppAccessMode = WebAppAccessMode.PRIVATE,
) -> tuple[WebAppAccessQueryService, MagicMock]:
allowed: bool = True,
) -> tuple[WebAppAccessQueryService, MagicMock, MagicMock]:
access_mode_for_app = MagicMock(return_value=access_mode)
is_user_allowed_for_app = MagicMock(return_value=allowed)
return (
WebAppAccessQueryService(
access=access,
webapp_auth_enabled=enabled,
access_mode_for_app=access_mode_for_app,
is_user_allowed_for_app=is_user_allowed_for_app,
),
access_mode_for_app,
is_user_allowed_for_app,
)
def test_disabled_auth_returns_public_before_resolving_app() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, access_mode_for_app = _service(access=access, enabled=False)
service, access_mode_for_app, _ = _service(access=access, enabled=False)
assert service.get_access_mode(app_id=None, app_code=None) is WebAppAccessMode.PUBLIC
access.find_app_id_by_code.assert_not_called()
@@ -39,7 +43,7 @@ def test_disabled_auth_returns_public_before_resolving_app() -> None:
def test_enabled_auth_reads_access_mode_by_app_id() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, access_mode_for_app = _service(access=access)
service, access_mode_for_app, _ = _service(access=access)
assert service.get_access_mode(app_id="app-1", app_code=None) is WebAppAccessMode.PRIVATE
access.find_app_id_by_code.assert_not_called()
@@ -49,7 +53,7 @@ def test_enabled_auth_reads_access_mode_by_app_id() -> None:
def test_app_code_takes_precedence_over_app_id() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
access.find_app_id_by_code.return_value = "resolved-id"
service, access_mode_for_app = _service(access=access, access_mode=WebAppAccessMode.SSO_VERIFIED)
service, access_mode_for_app, _ = _service(access=access, access_mode=WebAppAccessMode.SSO_VERIFIED)
assert service.get_access_mode(app_id="ignored-id", app_code="code-1") is WebAppAccessMode.SSO_VERIFIED
access.find_app_id_by_code.assert_called_once_with("code-1")
@@ -59,7 +63,7 @@ def test_app_code_takes_precedence_over_app_id() -> None:
def test_missing_app_code_raises_not_found() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
access.find_app_id_by_code.return_value = None
service, access_mode_for_app = _service(access=access)
service, access_mode_for_app, _ = _service(access=access)
with pytest.raises(WebAppAccessAppNotFoundError):
service.get_access_mode(app_id="must-not-fallback", app_code="missing-code")
@@ -69,7 +73,7 @@ def test_missing_app_code_raises_not_found() -> None:
def test_enabled_auth_requires_app_id_or_code() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, access_mode_for_app = _service(access=access)
service, access_mode_for_app, _ = _service(access=access)
with pytest.raises(WebAppAccessReferenceRequiredError, match="^appId or appCode must be provided$"):
service.get_access_mode(app_id=None, app_code=None)
@@ -81,7 +85,7 @@ def test_repository_failure_is_not_hidden() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
failure = TypeError("repository bug")
access.find_app_id_by_code.side_effect = failure
service, _ = _service(access=access)
service, _, _ = _service(access=access)
with pytest.raises(TypeError) as raised:
service.get_access_mode(app_id=None, app_code="code-1")
@@ -91,7 +95,7 @@ def test_repository_failure_is_not_hidden() -> None:
def test_access_mode_failure_is_not_hidden() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, access_mode_for_app = _service(access=access)
service, access_mode_for_app, _ = _service(access=access)
failure = TypeError("adapter bug")
access_mode_for_app.side_effect = failure
@@ -99,3 +103,48 @@ def test_access_mode_failure_is_not_hidden() -> None:
service.get_access_mode(app_id="app-1", app_code=None)
assert raised.value is failure
@pytest.mark.parametrize(
("access_mode", "expected"),
[
pytest.param(WebAppAccessMode.PUBLIC, False, id="public"),
pytest.param(WebAppAccessMode.SSO_VERIFIED, False, id="sso-verified"),
pytest.param(WebAppAccessMode.PRIVATE, True, id="private"),
pytest.param(WebAppAccessMode.PRIVATE_ALL, True, id="private-all"),
],
)
def test_requires_permission_check_for_private_modes(access_mode: WebAppAccessMode, expected: bool) -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, access_mode_for_app, _ = _service(access=access, access_mode=access_mode)
assert service.requires_permission_check("app-1") is expected
access_mode_for_app.assert_called_once_with("app-1")
def test_disabled_auth_still_reads_configured_mode_before_passport() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, access_mode_for_app, _ = _service(
access=access,
enabled=False,
access_mode=WebAppAccessMode.PRIVATE,
)
assert service.requires_permission_check("app-1") is True
access_mode_for_app.assert_called_once_with("app-1")
def test_disabled_auth_allows_after_passport_without_querying_user_permission() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, _, is_user_allowed_for_app = _service(access=access, enabled=False, allowed=False)
assert service.is_user_allowed(user_id="user-1", app_id="app-1") is True
is_user_allowed_for_app.assert_not_called()
def test_enabled_auth_delegates_user_permission() -> None:
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
service, _, is_user_allowed_for_app = _service(access=access, allowed=False)
assert service.is_user_allowed(user_id="user-1", app_id="app-1") is False
is_user_allowed_for_app.assert_called_once_with("user-1", "app-1")
@@ -1568,6 +1568,7 @@ export type GetWebappPermissionErrors = {
400: unknown
401: unknown
500: unknown
503: unknown
}
export type GetWebappPermissionResponses = {