From bc0636c4db146b813124b21905c101432abbca0a Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:42:53 +0000 Subject: [PATCH] refactor(openapi): route /openapi/v1 auth by subject (#41420) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/openapi/_contract.py | 90 +- api/controllers/openapi/_models.py | 5 +- api/controllers/openapi/account.py | 64 +- api/controllers/openapi/app_dsl.py | 111 +- api/controllers/openapi/app_run.py | 58 +- api/controllers/openapi/apps.py | 126 +- .../openapi/apps_permitted_external.py | 66 +- api/controllers/openapi/auth/__init__.py | 3 - api/controllers/openapi/auth/composition.py | 82 - api/controllers/openapi/auth/conditions.py | 65 - api/controllers/openapi/auth/context.py | 90 ++ api/controllers/openapi/auth/data.py | 59 +- api/controllers/openapi/auth/flow.py | 19 - api/controllers/openapi/auth/loaders.py | 137 ++ api/controllers/openapi/auth/pipeline.py | 285 ---- api/controllers/openapi/auth/pipelines.py | 102 ++ api/controllers/openapi/auth/prepare.py | 112 -- api/controllers/openapi/auth/requirements.py | 208 +++ api/controllers/openapi/auth/router.py | 81 + api/controllers/openapi/auth/spec.py | 23 + api/controllers/openapi/auth/subjects.py | 150 ++ api/controllers/openapi/auth/verify.py | 122 -- api/controllers/openapi/files.py | 31 +- api/controllers/openapi/flask_admission.py | 88 -- api/controllers/openapi/human_input_form.py | 110 +- api/controllers/openapi/oauth_device.py | 14 +- api/controllers/openapi/oauth_device_sso.py | 15 +- api/controllers/openapi/workflow_events.py | 60 +- api/controllers/openapi/workspaces.py | 184 ++- api/libs/oauth_bearer.py | 244 ++- api/openapi/markdown/openapi-openapi.md | 13 +- api/services/account_service.py | 10 +- api/services/app_service.py | 19 - api/services/oauth_device_flow.py | 21 +- api/services/openapi/mint_policy.py | 47 - .../controllers/openapi/conftest.py | 5 +- .../controllers/openapi/conftest.py | 73 +- .../controllers/openapi/test_account.py | 14 +- .../openapi/test_account_sessions.py | 51 +- .../controllers/openapi/test_app_dsl.py | 55 +- .../controllers/openapi/test_app_run.py | 15 +- .../controllers/openapi/test_apps.py | 51 +- .../controllers/openapi/test_files.py | 9 +- .../controllers/openapi/test_workspaces.py | 42 +- .../pyrefly.toml | 7 + api/tests/unit_tests/.ruff.toml | 3 - .../controllers/openapi/auth/_world.py | 127 ++ .../openapi/auth/test_composition.py | 223 --- .../openapi/auth/test_conditions.py | 208 --- .../controllers/openapi/auth/test_context.py | 35 + .../controllers/openapi/auth/test_data.py | 141 -- .../controllers/openapi/auth/test_flow.py | 42 - .../controllers/openapi/auth/test_loaders.py | 208 +++ .../controllers/openapi/auth/test_pipeline.py | 321 ---- .../openapi/auth/test_pipelines.py | 239 +++ .../controllers/openapi/auth/test_prepare.py | 346 ----- .../openapi/auth/test_requirements.py | 229 +++ .../controllers/openapi/auth/test_router.py | 364 +++++ .../controllers/openapi/auth/test_subjects.py | 127 ++ .../controllers/openapi/auth/test_verify.py | 268 ---- .../controllers/openapi/conftest.py | 140 +- .../controllers/openapi/test_account.py | 157 +- .../controllers/openapi/test_app_dsl.py | 14 +- .../controllers/openapi/test_app_payloads.py | 20 +- .../openapi/test_app_run_streaming.py | 128 +- .../test_apps_permitted_external_query.py | 43 +- .../controllers/openapi/test_auth_matrix.py | 1342 +++++++++++++++++ .../controllers/openapi/test_contract.py | 107 +- .../openapi/test_error_contract.py | 104 +- .../controllers/openapi/test_files.py | 35 +- .../openapi/test_flask_admission.py | 116 -- .../openapi/test_human_input_form.py | 309 ++-- .../controllers/openapi/test_models.py | 27 +- .../openapi/test_workflow_events_openapi.py | 271 ++-- .../controllers/openapi/test_workspaces.py | 58 - .../openapi/test_workspaces_members.py | 668 ++++---- .../unit_tests/libs/test_oauth_bearer.py | 57 +- .../test_oauth_bearer_rate_limit_ordering.py | 65 +- api/tests/unit_tests/pyrefly.toml | 12 - .../unit_tests/services/openapi/__init__.py | 0 .../services/openapi/test_mint_policy.py | 74 - .../unit_tests/services/test_app_service.py | 25 - .../services/test_oauth_device_flow_mint.py | 44 + .../generated/api/openapi/types.gen.ts | 21 +- .../generated/api/openapi/zod.gen.ts | 9 +- 85 files changed, 5193 insertions(+), 4770 deletions(-) delete mode 100644 api/controllers/openapi/auth/composition.py delete mode 100644 api/controllers/openapi/auth/conditions.py create mode 100644 api/controllers/openapi/auth/context.py delete mode 100644 api/controllers/openapi/auth/flow.py create mode 100644 api/controllers/openapi/auth/loaders.py delete mode 100644 api/controllers/openapi/auth/pipeline.py create mode 100644 api/controllers/openapi/auth/pipelines.py delete mode 100644 api/controllers/openapi/auth/prepare.py create mode 100644 api/controllers/openapi/auth/requirements.py create mode 100644 api/controllers/openapi/auth/router.py create mode 100644 api/controllers/openapi/auth/spec.py create mode 100644 api/controllers/openapi/auth/subjects.py delete mode 100644 api/controllers/openapi/auth/verify.py delete mode 100644 api/controllers/openapi/flask_admission.py delete mode 100644 api/services/openapi/mint_policy.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/_world.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_composition.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_conditions.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_context.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_data.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_flow.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_loaders.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_prepare.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_requirements.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_router.py create mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_subjects.py delete mode 100644 api/tests/unit_tests/controllers/openapi/auth/test_verify.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_auth_matrix.py delete mode 100644 api/tests/unit_tests/controllers/openapi/test_flask_admission.py delete mode 100644 api/tests/unit_tests/controllers/openapi/test_workspaces.py delete mode 100644 api/tests/unit_tests/services/openapi/__init__.py delete mode 100644 api/tests/unit_tests/services/openapi/test_mint_policy.py create mode 100644 api/tests/unit_tests/services/test_oauth_device_flow_mint.py diff --git a/api/controllers/openapi/_contract.py b/api/controllers/openapi/_contract.py index aa92cdf411d..eb17bacb085 100644 --- a/api/controllers/openapi/_contract.py +++ b/api/controllers/openapi/_contract.py @@ -1,20 +1,18 @@ -"""Request/response contract decorators for the openapi controllers. +"""``@accepts`` and ``@returns`` own one slice of the contract from a single model +reference, so the advertised and enforced contracts can't drift. -``@accepts`` and ``@returns`` own one slice of the contract from a single model -reference — emitting the Swagger schema AND doing the runtime validation/ -serialisation — so the advertised and enforced contracts can't drift. Validation -failures map to a single shape: 422. - -They must sit below route admission (or a direct ``@auth_router.guard``) so -authentication runs before validation and the ``view.__wrapped__`` unit-test -seam can bypass the outer admission layer. +``@endpoint`` composes both under ``subject_router.guard``, exposing +``view.__handler__`` as the one documented test seam. ``returns`` is still used +bare by the unauthenticated ``index.py`` probes, which have no auth layer to +compose with. """ from __future__ import annotations -from collections.abc import Callable +import inspect +from collections.abc import Callable, Sequence from functools import wraps -from typing import Any +from typing import Any, cast from flask import request from flask_restx import abort @@ -23,14 +21,14 @@ from pydantic import BaseModel, ValidationError from controllers.common.schema import query_params_from_model, query_params_from_request from controllers.openapi import openapi_ns from controllers.openapi._errors import ErrorBody +from controllers.openapi.auth.requirements import Requirement +from controllers.openapi.auth.router import subject_router +from controllers.openapi.auth.spec import EndpointSpec +from enums import DeploymentEdition def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable: - """Validate ``query``/``body`` against the models and inject them as keyword-only kwargs. - - Emits the matching Swagger schema from the same models, so doc and enforcement - stay in lockstep. - """ + """Validate ``query``/``body`` against the models and inject them as keyword-only kwargs.""" def decorator(view: Callable) -> Callable: @wraps(view) @@ -84,3 +82,63 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) - return wrapper return decorator + + +_apply_returns = returns + +ReturnSpec = tuple[int, type[BaseModel], str | None] + + +def _normalize_returns(returns: ReturnSpec | Sequence[ReturnSpec] | None) -> tuple[ReturnSpec, ...]: + """One ``(code, model, description)`` or several, always ending up a tuple of + them — the one place that tells the two apart, so `decorator` below never has to. + """ + if returns is None: + return () + if isinstance(returns[0], int): + return (cast(ReturnSpec, returns),) # pyrefly: ignore[redundant-cast] + return tuple(cast(Sequence[ReturnSpec], returns)) # pyrefly: ignore[redundant-cast] + + +def endpoint( + *, + requirements: Sequence[Requirement] = (), + query: type[BaseModel] | None = None, + body: type[BaseModel] | None = None, + returns: ReturnSpec | Sequence[ReturnSpec] | None = None, + edition: frozenset[DeploymentEdition] | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """The one seam a route attaches to for auth, request validation and response + serialisation — auth, then ``accepts``, then ``returns``. Exposes + ``view.__handler__`` (the bare handler) and ``view.__spec__`` (the exact + `EndpointSpec` instance the router runs). + + ``returns`` takes one ``(code, model, description)`` or several; a route + that stacked N ``@returns`` today declares them here in the same top-to-bottom + order and gets the identical nesting back — first entry outermost, last entry + closest to the handler — including the N-times ``"default"`` error registration + that stacking N ``@returns`` already produces. + """ + requirements = tuple(requirements) + for requirement in requirements: + if not isinstance(requirement, Requirement): + raise TypeError(f"requirements must be instances of Requirement, not {requirement!r}") + spec = EndpointSpec(requirements=requirements, edition=edition) + return_specs = _normalize_returns(returns) + + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + if "ctx" not in inspect.signature(view).parameters: + raise TypeError(f"{view.__qualname__} must declare a 'ctx' parameter") + + decorated = view + if query is not None or body is not None: + decorated = accepts(query=query, body=body)(decorated) + for return_spec in reversed(return_specs): + decorated = _apply_returns(*return_spec)(decorated) + decorated = subject_router.guard(spec)(decorated) + + decorated.__handler__ = view # type: ignore[attr-defined] + decorated.__spec__ = spec # type: ignore[attr-defined] + return decorated + + return decorator diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 530f2afeb0d..b54bf239789 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida from enums import DeploymentEdition from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value +from libs.oauth_bearer import SubjectType from models.model import AppMode # Server-side cap on `limit` query param for /openapi/v1/* list endpoints. @@ -129,7 +130,7 @@ class WorkspacePayload(BaseModel): class DeviceTokenResponse(BaseModel): token: str expires_at: str - subject_type: Literal["account", "external_sso"] + subject_type: SubjectType account: AccountPayload | None = None workspaces: list[WorkspacePayload] = [] default_workspace_id: str | None = None @@ -139,7 +140,7 @@ class DeviceTokenResponse(BaseModel): class AccountResponse(BaseModel): - subject_type: str + subject_type: SubjectType subject_email: str | None = None subject_issuer: str | None = None account: AccountPayload | None = None diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py index 6ddd9a14ef2..d444c2bb20d 100644 --- a/api/controllers/openapi/account.py +++ b/api/controllers/openapi/account.py @@ -3,11 +3,12 @@ from __future__ import annotations from datetime import UTC, datetime from uuid import UUID +from flask import request from flask_restx import Resource from werkzeug.exceptions import NotFound, Unauthorized from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._models import ( AccountPayload, AccountResponse, @@ -17,10 +18,13 @@ from controllers.openapi._models import ( SessionRow, WorkspacePayload, ) -from controllers.openapi.flask_admission import openapi_account_admission +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import CheckScope, CheckSubject +from controllers.openapi.auth.subjects import AccountSubject +from core.logging.context import get_request_id, get_trace_id from extensions.ext_application_services import application_services from libs.oauth_bearer import Scope -from libs.rate_limit import LIMIT_ME_PER_ACCOUNT +from libs.rate_limit import LIMIT_ME_PER_ACCOUNT, enforce from machinery.context import AccountRequestContext from services.account_errors import AccountNotFoundError, AccountSessionNotFoundError from services.entities.account_access_entities import AccountSessionSnapshot, AccountWorkspaceSnapshot @@ -29,15 +33,20 @@ from services.entities.account_entities import AccountSnapshot @openapi_ns.route("/account") class AccountApi(Resource): - @openapi_account_admission(scope=Scope.FULL, rate_limit=LIMIT_ME_PER_ACCOUNT) - @returns(200, AccountResponse, description="Account info") - def get(self, request_context: AccountRequestContext): + @endpoint( + requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), + returns=(200, AccountResponse, "Account info"), + ) + def get(self, ctx: Context): + request_context = _request_context(ctx) + enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{request_context.account_id}") + try: snapshot = application_services().accounts.access.get(request_context) except AccountNotFoundError: raise Unauthorized("account not found") from None return AccountResponse( - subject_type="account", + subject_type=ctx.subject.subject_type, subject_email=snapshot.account.email, account=_account_payload(snapshot.account), workspaces=[_workspace_payload(workspace) for workspace in snapshot.workspaces], @@ -47,21 +56,25 @@ class AccountApi(Resource): @openapi_ns.route("/account/sessions/self") class AccountSessionsSelfApi(Resource): - @openapi_account_admission(scope=Scope.FULL) - @returns(200, RevokeResponse, description="Session revoked") - def delete(self, request_context: AccountRequestContext): - application_services().accounts.access.revoke_current_session(request_context) + @endpoint( + requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), + returns=(200, RevokeResponse, "Session revoked"), + ) + def delete(self, ctx: Context): + application_services().accounts.access.revoke_current_session(_request_context(ctx)) return RevokeResponse(status="revoked") @openapi_ns.route("/account/sessions") class AccountSessionsApi(Resource): - @openapi_account_admission(scope=Scope.FULL) - @returns(200, SessionListResponse, description="Session list") - @accepts(query=SessionListQuery) - def get(self, request_context: AccountRequestContext, *, query: SessionListQuery): + @endpoint( + requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), + query=SessionListQuery, + returns=(200, SessionListResponse, "Session list"), + ) + def get(self, ctx: Context, *, query: SessionListQuery): page = application_services().accounts.access.list_sessions( - request_context, + _request_context(ctx), page=query.page, limit=query.limit, ) @@ -76,21 +89,32 @@ class AccountSessionsApi(Resource): @openapi_ns.route("/account/sessions/") class AccountSessionByIdApi(Resource): - @openapi_account_admission(scope=Scope.FULL) - @returns(200, RevokeResponse, description="Session revoked") - def delete(self, request_context: AccountRequestContext, session_id: str): + @endpoint( + requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.FULL)), + returns=(200, RevokeResponse, "Session revoked"), + ) + def delete(self, ctx: Context, session_id: str): try: token_id = str(UUID(session_id)) except ValueError: raise NotFound("session not found") from None try: - application_services().accounts.access.revoke_session(request_context, token_id=token_id) + application_services().accounts.access.revoke_session(_request_context(ctx), token_id=token_id) except AccountSessionNotFoundError: # Do not reveal whether a token ID belongs to another account. raise NotFound("session not found") from None return RevokeResponse(status="revoked") +def _request_context(ctx: Context) -> AccountRequestContext: + return AccountRequestContext( + request_id=get_request_id(), + trace_id=get_trace_id() or request.headers.get("X-Trace-Id"), + account_id=str(ctx.subject.account_id), + access_token_id=str(ctx.subject.token_id), + ) + + def _iso(dt: datetime | None) -> str | None: if dt is None: return None diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index 0978c2c628f..665f01b0999 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -1,20 +1,25 @@ from __future__ import annotations -from typing import cast - from flask_restx import Resource from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckAppApiEnabled, + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, + CheckWorkspaceRole, +) +from controllers.openapi.auth.subjects import AccountSubject from extensions.ext_database import db -from libs.oauth_bearer import Scope, TokenType -from models import Account, App +from libs.oauth_bearer import Scope from models.account import TenantAccountRole from services.app_dsl_service import AppDslService, Import from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus @@ -36,18 +41,23 @@ class AppDslImportApi(Resource): Returns 400 when the import failed due to invalid DSL or a business error. """ - @auth_router.guard_workspace( - scope=Scope.WORKSPACE_WRITE, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), - rbac=RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())), + CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), + ), + body=AppDslImportPayload, + returns=( + (200, Import, "Import completed"), + (202, Import, "Import pending confirmation"), + (400, Import, "Import failed"), + ), ) - @returns(200, Import, "Import completed") - @returns(202, Import, "Import pending confirmation") - @returns(400, Import, "Import failed") - @accepts(body=AppDslImportPayload) - def post(self, workspace_id: str, *, auth_data: AuthData, body: AppDslImportPayload): - account = cast(Account, auth_data.caller) + def post(self, ctx: Context, workspace_id: str, *, body: AppDslImportPayload): + account = ctx.account with Session(db.engine, expire_on_commit=False) as session: service = AppDslService(session) @@ -92,16 +102,18 @@ class AppDslImportConfirmApi(Resource): Returns 400 when the pending data has expired or the import fails. """ - @auth_router.guard_workspace( - scope=Scope.WORKSPACE_WRITE, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), - rbac=RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())), + CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), + ), + returns=((200, Import, "Import confirmed"), (400, Import, "Import failed")), ) - @returns(200, Import, "Import confirmed") - @returns(400, Import, "Import failed") - def post(self, workspace_id: str, import_id: str, *, auth_data: AuthData): - account = cast(Account, auth_data.caller) + def post(self, ctx: Context, workspace_id: str, import_id: str): + account = ctx.account with Session(db.engine, expire_on_commit=False) as session: service = AppDslService(session) @@ -132,19 +144,22 @@ class AppDslExportApi(Resource): receive a 403; enable the API in the console first if needed. """ - @auth_router.guard( - scope=Scope.APPS_READ, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), - rbac=RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())), + CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), + ), + query=AppDslExportQuery, + returns=(200, AppDslExportResponse, "Export successful"), ) - @accepts(query=AppDslExportQuery) - @returns(200, AppDslExportResponse, "Export successful") - def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery): - app = cast(App, auth_data.app) + def get(self, ctx: Context, app_id: str, *, query: AppDslExportQuery): try: data = AppDslService.export_dsl( - app_model=app, + app_model=ctx.app, session=db.session(), include_secret=query.include_secret, workflow_id=query.workflow_id, @@ -164,18 +179,20 @@ class AppDslCheckDependenciesApi(Resource): dependencies are satisfied. """ - @auth_router.guard( - scope=Scope.APPS_READ, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}), - rbac=RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())), + CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})), + ), + returns=(200, CheckDependenciesResult, "Dependencies checked"), ) - @returns(200, CheckDependenciesResult, "Dependencies checked") - def get(self, app_id: str, *, auth_data: AuthData): - app = cast(App, auth_data.app) - + def get(self, ctx: Context, app_id: str): with Session(db.engine, expire_on_commit=False) as session: service = AppDslService(session) - result = service.check_dependencies(app_model=app) + result = service.check_dependencies(app_model=ctx.app) return result, 200 diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py index e71d3e72bba..16315d685e5 100644 --- a/api/controllers/openapi/app_run.py +++ b/api/controllers/openapi/app_run.py @@ -21,13 +21,20 @@ from werkzeug.exceptions import ( import services from controllers.common.fields import EventStreamResponse from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission -from controllers.console.app.wraps import with_session from controllers.openapi import openapi_ns from controllers.openapi._audit import emit_app_run -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._models import AppRunRequest, TaskStopResponse -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, +) +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject from controllers.service_api.app.error import ( AppUnavailableError, CompletionRequestError, @@ -146,22 +153,28 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = { @openapi_ns.route("/apps/:run") class AppRunApi(Resource): - @auth_router.guard( - scope=Scope.APPS_RUN, - rbac=RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), + ), + body=AppRunRequest, + returns=(200, EventStreamResponse, "Run result (SSE stream)"), ) - @openapi_ns.response(200, "Run result (SSE stream)", openapi_ns.models[EventStreamResponse.__name__]) - @accepts(body=AppRunRequest) - @with_session - def post(self, session: Session, app_id: str, *, auth_data: AuthData, body: AppRunRequest): - app_model, caller, caller_kind = auth_data.require_app_context() + def post(self, ctx: Context, app_id: str, *, body: AppRunRequest): + app_model = ctx.app + caller = ctx.caller handler = _DISPATCH.get(app_model.mode) if handler is None: raise UnprocessableEntity("mode_not_runnable") try: - stream_obj = handler(app_model, caller, body, session) + stream_obj = handler(app_model, caller, body, ctx.session) except HTTPException: raise except Exception: @@ -171,7 +184,7 @@ class AppRunApi(Resource): emit_app_run( app_id=app_model.id, tenant_id=app_model.tenant_id, - caller_kind=caller_kind, + caller_kind=ctx.subject.caller_role, mode=str(app_model.mode), surface="apps", ) @@ -182,13 +195,18 @@ class AppRunApi(Resource): @openapi_ns.route("/apps//tasks/:stop") class AppRunTaskStopApi(Resource): - @auth_router.guard( - scope=Scope.APPS_RUN, - rbac=RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), + ), + returns=(200, TaskStopResponse, "Task stopped"), ) - @returns(200, TaskStopResponse, description="Task stopped") - def post(self, app_id: str, task_id: str, *, auth_data: AuthData): - app_model, caller, caller_kind = auth_data.require_app_context() + def post(self, ctx: Context, app_id: str, task_id: str): AppQueueManager.set_stop_flag_no_user_check(task_id) GraphEngineManager(redis_client).send_stop_command(task_id) return TaskStopResponse(result="success") diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 2675cac0400..0195bec24c0 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -7,15 +7,13 @@ from typing import Any from flask_restx import Resource from sqlalchemy.orm import Session -from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity from configs import dify_config from controllers.common.app_access import AppAccessFilter, resolve_app_access_filter from controllers.common.fields import Parameters from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission -from controllers.common.session import with_session from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config from controllers.openapi._models import ( SUPPORTED_APP_TYPES, @@ -26,11 +24,18 @@ from controllers.openapi._models import ( AppListResponse, AppListRow, ) -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, CallerKind +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckAppApiEnabled, + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, +) +from controllers.openapi.auth.subjects import AccountSubject from controllers.service_api.app.error import AppUnavailableError from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict -from libs.oauth_bearer import Scope, TokenType +from libs.oauth_bearer import Scope from models import App from models.enums import AppStatus from models.model import AppMode @@ -52,39 +57,6 @@ _EMPTY_PARAMETERS: dict[str, Any] = { } -class AppReadResource(Resource): - """Base for per-app read endpoints; subclasses call `_load()` for membership/exists checks.""" - - def _load(self, session: Session, app_id: str, workspace_id: str | None = None) -> App: - try: - parsed_uuid = _uuid.UUID(app_id) - is_uuid = True - except ValueError: - parsed_uuid = None - is_uuid = False - - if is_uuid: - # ``str(parsed_uuid)`` normalises to the canonical dashed form. - app = AppService.get_visible_app_by_id(str(parsed_uuid), session) - if app is None: - raise NotFound("app not found") - else: - if not workspace_id: - raise UnprocessableEntity("workspace_id is required for name-based lookup") - matches = AppService.find_visible_apps_by_name(session, name=app_id, tenant_id=workspace_id) - if len(matches) == 0: - raise NotFound("app not found") - if len(matches) > 1: - lines = [f"app name {app_id!r} is ambiguous — re-run with a UUID:\n\n"] - lines.append(f" {'ID':<36} {'MODE':<12} NAME\n") - for m in matches: - lines.append(f" {str(m.id):<36} {str(m.mode.value):<12} {m.name}\n") - raise Conflict("".join(lines)) - app = matches[0] - - return app - - def parameters_payload(app: App, *, session: Session) -> dict: """Mirrors service_api/app/app.py::AppParameterApi response body.""" features_dict, user_input_form = resolve_app_config(app, session=session) @@ -129,29 +101,37 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S @openapi_ns.route("/apps/") -class AppDescribeApi(AppReadResource): - @auth_router.guard( - scope=Scope.APPS_READ, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - rbac=RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()), +class AppDescribeApi(Resource): + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())), + ), + query=AppDescribeQuery, + returns=(200, AppDescribeResponse, "App description"), ) - @returns(200, AppDescribeResponse, description="App description") - @accepts(query=AppDescribeQuery) - @with_session(write=False) - def get(self, session: Session, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery): - # describe is UUID-only (workspace_id query param dropped in #37212). - app = self._load(session, app_id) - return build_app_describe_response(app, query.fields, session=session) + def get(self, ctx: Context, app_id: str, *, query: AppDescribeQuery): + # The pipeline has already loaded the app; project it. + return build_app_describe_response(ctx.app, query.fields, session=ctx.session) @openapi_ns.route("/apps") class AppListApi(Resource): - @auth_router.guard_workspace(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - @returns(200, AppListResponse, description="App list") - @accepts(query=AppListQuery) - @with_session(write=False) - def get(self, session: Session, *, auth_data: AuthData, query: AppListQuery): + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.APPS_READ), + CheckWorkspaceMember(), + ), + query=AppListQuery, + returns=(200, AppListResponse, "App list"), + ) + def get(self, ctx: Context, *, query: AppListQuery): workspace_id = query.workspace_id + account_id = str(ctx.subject.account_id) empty = AppListResponse.build(page=query.page, limit=query.limit, total=0, items=[]) @@ -163,37 +143,26 @@ class AppListApi(Resource): else: parsed_uuid = None - # Compute RBAC-accessible app IDs when RBAC is enabled and the caller is an account. - # ``None`` means unrestricted (caller can see all apps in the workspace); - # an empty set or list means the caller has no accessible apps. - # End-users bypass RBAC here — their access is controlled by scope upstream. - apply_rbac_filter = ( - dify_config.RBAC_ENABLED - and auth_data.caller_kind != CallerKind.END_USER - and auth_data.account_id is not None + access_filter = ( + resolve_app_access_filter(workspace_id, account_id, session=ctx.session) + if dify_config.RBAC_ENABLED + else AppAccessFilter.unrestricted() ) - access_filter = AppAccessFilter.unrestricted() - if apply_rbac_filter: - access_filter = resolve_app_access_filter( - workspace_id, - str(auth_data.account_id), - session=session, - ) tenant_name: str | None = None if parsed_uuid is not None: - app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), session) + app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), ctx.session) if app is None or str(app.tenant_id) != workspace_id: return empty if not _is_listable(app): return empty # Apply RBAC visibility to the UUID fast-path the same way the service # layer does for paginated queries (id in accessible set OR own app). - if apply_rbac_filter and not access_filter.is_app_accessible( - str(app.id), str(app.maintainer) if app.maintainer else None, str(auth_data.account_id) + if not access_filter.is_app_accessible( + str(app.id), str(app.maintainer) if app.maintainer else None, account_id ): return empty - tenant_name = TenantService.get_tenant_name(workspace_id, session=session) + tenant_name = TenantService.get_tenant_name(workspace_id, session=ctx.session) item = AppListRow( id=str(app.id), name=app.name, @@ -217,16 +186,15 @@ class AppListApi(Resource): openapi_visible=True, ) - if apply_rbac_filter: - access_filter.apply_to_params(params) + access_filter.apply_to_params(params) - pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, session) + pagination = AppService().get_paginate_apps(account_id, workspace_id, params, ctx.session) if pagination is None: return empty tenant_name = None if pagination.items: - tenant_name = TenantService.get_tenant_name(workspace_id, session=session) + tenant_name = TenantService.get_tenant_name(workspace_id, session=ctx.session) items = [ AppListRow( diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 284ec0713d9..6d1a82f9ebc 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -8,12 +8,9 @@ EE blueprint chain so this module is unreachable there. from __future__ import annotations from flask_restx import Resource -from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound -from controllers.common.session import with_session from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._models import ( AppDescribeQuery, AppDescribeResponse, @@ -22,28 +19,37 @@ from controllers.openapi._models import ( PermittedExternalAppsListResponse, ) from controllers.openapi.apps import build_app_describe_response -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckScope, + CheckSubject, +) +from controllers.openapi.auth.subjects import ExternalSsoSubject from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType +from libs.oauth_bearer import Scope from models import App from models.enums import AppStatus from services.account_service import TenantService from services.app_service import AppService from services.enterprise.app_permitted_service import list_permitted_apps +_ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE}) + @openapi_ns.route("/permitted-external-apps") class PermittedExternalAppsListApi(Resource): - @auth_router.guard( - scope=Scope.APPS_READ_PERMITTED_EXTERNAL, - allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}), - edition=frozenset({DeploymentEdition.ENTERPRISE}), + @endpoint( + requirements=( + CheckSubject(allowed=(ExternalSsoSubject,)), + CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL), + ), + query=PermittedExternalAppsListQuery, + returns=(200, PermittedExternalAppsListResponse, "Permitted external apps list"), + edition=_ENTERPRISE_ONLY, ) - @returns(200, PermittedExternalAppsListResponse, description="Permitted external apps list") - @accepts(query=PermittedExternalAppsListQuery) - @with_session(write=False) - def get(self, session: Session, *, auth_data: AuthData, query: PermittedExternalAppsListQuery): + def get(self, ctx: Context, *, query: PermittedExternalAppsListQuery): page_result = list_permitted_apps( page=query.page, limit=query.limit, @@ -58,10 +64,10 @@ class PermittedExternalAppsListApi(Resource): return env apps_by_id: dict[str, App] = { - str(a.id): a for a in AppService.find_visible_apps_by_ids(page_result.app_ids, session) + str(a.id): a for a in AppService.find_visible_apps_by_ids(page_result.app_ids, ctx.session) } tenant_ids = list({str(a.tenant_id) for a in apps_by_id.values()}) - tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(tenant_ids, session=session)} + tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(tenant_ids, session=ctx.session)} items: list[AppListRow] = [] for app_id in page_result.app_ids: @@ -88,17 +94,17 @@ class PermittedExternalAppsListApi(Resource): @openapi_ns.route("/permitted-external-apps/") class PermittedExternalAppDescribeApi(Resource): - @auth_router.guard( - scope=Scope.APPS_READ_PERMITTED_EXTERNAL, - allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}), - edition=frozenset({DeploymentEdition.ENTERPRISE}), + @endpoint( + requirements=( + CheckSubject(allowed=(ExternalSsoSubject,)), + CheckAppApiEnabled(), + CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL), + CheckAppAccess(), + ), + query=AppDescribeQuery, + returns=(200, AppDescribeResponse, "Permitted external app description"), + edition=_ENTERPRISE_ONLY, ) - @returns(200, AppDescribeResponse, description="Permitted external app description") - @accepts(query=AppDescribeQuery) - @with_session(write=False) - def get(self, session: Session, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery): - # App already loaded and ACL-checked by the external_sso pipeline; project it. - app = auth_data.app - if app is None: - raise NotFound("app not found") - return build_app_describe_response(app, query.fields, session=session) + def get(self, ctx: Context, app_id: str, *, query: AppDescribeQuery): + # The pipeline has already loaded and ACL-checked the app; project it. + return build_app_describe_response(ctx.app, query.fields, session=ctx.session) diff --git a/api/controllers/openapi/auth/__init__.py b/api/controllers/openapi/auth/__init__.py index 0460788c183..e69de29bb2d 100644 --- a/api/controllers/openapi/auth/__init__.py +++ b/api/controllers/openapi/auth/__init__.py @@ -1,3 +0,0 @@ -from controllers.openapi.auth.composition import auth_router - -__all__ = ["auth_router"] diff --git a/api/controllers/openapi/auth/composition.py b/api/controllers/openapi/auth/composition.py deleted file mode 100644 index 9040058b85f..00000000000 --- a/api/controllers/openapi/auth/composition.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from controllers.openapi.auth.conditions import ( - EDITION_ENTERPRISE, - HAS_ALLOWED_ROLES, - HAS_RBAC, - LOADED_APP_IS_PRIVATE, - PATH_HAS_APP_ID, - WEBAPP_AUTH_ENABLED, - WEBAPP_RUN_SCOPED, - WORKSPACE_MEMBERSHIP_REQUIRED, - WORKSPACE_SCOPED, -) -from controllers.openapi.auth.flow import When -from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter -from controllers.openapi.auth.prepare import ( - load_account, - load_app, - load_app_access_mode, - load_tenant, - load_tenant_from_request, - load_workspace_role, - resolve_external_user, -) -from controllers.openapi.auth.verify import ( - check_acl, - check_app_api_enabled, - check_private_app_permission, - check_rbac_permission, - check_scope, - check_workspace_member, - check_workspace_mismatch, - check_workspace_role, -) -from enums import DeploymentEdition -from libs.oauth_bearer import TokenType - -account_pipeline = AuthPipeline( - prepare=[ - When(PATH_HAS_APP_ID, then=load_app), - When(PATH_HAS_APP_ID, then=load_tenant), - When(WORKSPACE_MEMBERSHIP_REQUIRED, then=load_tenant_from_request), - load_account, - When(WORKSPACE_SCOPED, then=load_workspace_role), - When(PATH_HAS_APP_ID & EDITION_ENTERPRISE, then=load_app_access_mode), - ], - auth=[ - When(PATH_HAS_APP_ID, then=check_app_api_enabled), - check_scope, - When(WORKSPACE_SCOPED, then=check_workspace_member), - When(PATH_HAS_APP_ID, then=check_workspace_mismatch), - When(HAS_ALLOWED_ROLES, then=check_workspace_role), - When(HAS_RBAC, then=check_rbac_permission), - When(PATH_HAS_APP_ID & EDITION_ENTERPRISE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl), - When(EDITION_ENTERPRISE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission), - ], -) - -external_sso_pipeline = AuthPipeline( - prepare=[ - When(PATH_HAS_APP_ID, then=load_app), - When(PATH_HAS_APP_ID, then=load_tenant), - When(PATH_HAS_APP_ID, then=resolve_external_user), - When(PATH_HAS_APP_ID, then=load_app_access_mode), - ], - auth=[ - When(PATH_HAS_APP_ID, then=check_app_api_enabled), - check_scope, - When(PATH_HAS_APP_ID & WEBAPP_AUTH_ENABLED, then=check_acl), - When(LOADED_APP_IS_PRIVATE, then=check_private_app_permission), - ], -) - -auth_router = PipelineRouter( - { - TokenType.OAUTH_ACCOUNT: PipelineRoute(account_pipeline), - TokenType.OAUTH_EXTERNAL_SSO: PipelineRoute( - external_sso_pipeline, - required_edition=frozenset({DeploymentEdition.ENTERPRISE}), - ), - } -) diff --git a/api/controllers/openapi/auth/conditions.py b/api/controllers/openapi/auth/conditions.py deleted file mode 100644 index 49da949ccab..00000000000 --- a/api/controllers/openapi/auth/conditions.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable - -from configs import dify_config -from controllers.openapi.auth.data import AuthData, RequestContext -from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType -from services.enterprise.enterprise_service import WebAppAccessMode -from services.system_feature_service import SystemFeatureService - -CondFn = Callable[[RequestContext, AuthData | None], bool] - - -class Cond: - def __init__(self, fn: CondFn) -> None: - self._fn = fn - - def __call__(self, ctx: RequestContext, data: AuthData | None = None) -> bool: - return self._fn(ctx, data) - - def __and__(self, other: Cond) -> Cond: - return Cond(lambda ctx, data: self(ctx, data) and other(ctx, data)) - - def __or__(self, other: Cond) -> Cond: - return Cond(lambda ctx, data: self(ctx, data) or other(ctx, data)) - - def __invert__(self) -> Cond: - return Cond(lambda ctx, data: not self(ctx, data)) - - -def request_cond(fn: Callable[[RequestContext], bool]) -> Cond: - return Cond(lambda ctx, _: fn(ctx)) - - -def data_cond(fn: Callable[[AuthData], bool]) -> Cond: - return Cond(lambda _, data: data is not None and fn(data)) - - -def config_cond(fn: Callable[[], bool]) -> Cond: - return Cond(lambda _, __: fn()) - - -TOKEN_IS_OAUTH_ACCOUNT = request_cond(lambda ctx: ctx.token_type == TokenType.OAUTH_ACCOUNT) -TOKEN_IS_OAUTH_EXTERNAL_SSO = request_cond(lambda ctx: ctx.token_type == TokenType.OAUTH_EXTERNAL_SSO) - -PATH_HAS_APP_ID = request_cond(lambda ctx: "app_id" in ctx.path_params) - -EDITION_COMMUNITY = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY) -EDITION_ENTERPRISE = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE) -EDITION_CLOUD = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD) - -WEBAPP_AUTH_ENABLED = config_cond(lambda: SystemFeatureService.is_webapp_auth_enabled()) - -WEBAPP_RUN_SCOPED = request_cond(lambda ctx: ctx.scope == Scope.APPS_RUN) - -WORKSPACE_MEMBERSHIP_REQUIRED = request_cond(lambda ctx: ctx.workspace_membership) -HAS_ALLOWED_ROLES = request_cond(lambda ctx: ctx.allowed_roles is not None) -HAS_RBAC = request_cond(lambda ctx: ctx.rbac is not None) - -# Caller must belong to the resolved tenant: either an app-scoped path (tenant -# from the app) or an explicit workspace-membership path (tenant from request). -WORKSPACE_SCOPED = PATH_HAS_APP_ID | WORKSPACE_MEMBERSHIP_REQUIRED - -LOADED_APP_IS_PRIVATE = data_cond(lambda data: data.app_access_mode == WebAppAccessMode.PRIVATE) diff --git a/api/controllers/openapi/auth/context.py b/api/controllers/openapi/auth/context.py new file mode 100644 index 00000000000..5b44d23d761 --- /dev/null +++ b/api/controllers/openapi/auth/context.py @@ -0,0 +1,90 @@ +"""What the request resolved, and nothing else: no fetching, no checking, no +opinion about the shape of the request. `loaders.py` fills a slot once; the +properties hand handlers the non-optional value, and raise when no requirement +on the route loaded it, so a handler can never fetch its way past a missing +declaration. + +A subject resolves its caller through the loaders, so the import of `Subject` +here is type-only: a runtime one would close the cycle +`context` -> `subjects` -> `loaders` -> `context`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Session + +from models.account import Account, Tenant, TenantAccountRole +from models.model import App, EndUser + +if TYPE_CHECKING: + from controllers.openapi.auth.subjects import Subject + +type Caller = Account | EndUser + + +class RouteContractError(LookupError): + """A route's declarations and its code disagree: a handler read a slot no + requirement loaded, read the caller as a type the route does not admit, or + a requirement asked for a path parameter the route does not carry. Never + caught on purpose - it only ever means an endpoint was wired wrong. + """ + + +@dataclass +class Context: + subject: Subject + session: Session + view_args: Mapping[str, str] + _app: App | None = field(default=None, init=False) + _workspace: Tenant | None = field(default=None, init=False) + _workspace_role: TenantAccountRole | None = field(default=None, init=False) + _caller: Caller | None = field(default=None, init=False) + + @property + def app(self) -> App: + return _loaded(self._app, "app") + + @property + def workspace(self) -> Tenant: + return _loaded(self._workspace, "workspace") + + @property + def workspace_role(self) -> TenantAccountRole: + return _loaded(self._workspace_role, "workspace_role") + + @property + def caller(self) -> Caller: + # Spelled out: a generic over `T | None` would widen the union to its base. + if self._caller is None: + raise _missing("caller") + return self._caller + + @property + def account(self) -> Account: + return _narrowed(self.caller, Account) + + @property + def end_user(self) -> EndUser: + return _narrowed(self.caller, EndUser) + + +def _loaded[T](value: T | None, name: str) -> T: + if value is None: + raise _missing(name) + return value + + +def _missing(name: str) -> RouteContractError: + return RouteContractError(f"{name} was not loaded: no requirement on this route asked for it") + + +def _narrowed[C: Caller](caller: Caller, expected: type[C]) -> C: + if not isinstance(caller, expected): + raise RouteContractError( + f"the caller is a {type(caller).__name__}, not the {expected.__name__} this handler reads" + ) + return caller diff --git a/api/controllers/openapi/auth/data.py b/api/controllers/openapi/auth/data.py index de6f6eaf0fa..372b9ebd079 100644 --- a/api/controllers/openapi/auth/data.py +++ b/api/controllers/openapi/auth/data.py @@ -1,21 +1,8 @@ +"""The identity an external subject carries.""" + from __future__ import annotations -import uuid -from enum import StrEnum - -from pydantic import BaseModel, ConfigDict, Field -from werkzeug.exceptions import InternalServerError - -from controllers.common.rbac import RBACCheck -from libs.oauth_bearer import Scope, TokenType -from models.account import Account, Tenant, TenantAccountRole -from models.model import App, EndUser -from services.enterprise.enterprise_service import WebAppAccessMode - - -class CallerKind(StrEnum): - ACCOUNT = "account" - END_USER = "end_user" +from pydantic import BaseModel, ConfigDict class ExternalIdentity(BaseModel): @@ -23,43 +10,3 @@ class ExternalIdentity(BaseModel): email: str issuer: str | None = None - - -class RequestContext(BaseModel): - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - token_type: TokenType - scope: Scope | None = None - path_params: dict[str, str] - workspace_membership: bool = False - allowed_roles: frozenset[TenantAccountRole] | None = None - rbac: RBACCheck | None = None - - -class AuthData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - - required_scope: Scope | None = None - token_type: TokenType - account_id: uuid.UUID | None = None - token_id: uuid.UUID | None = None - scopes: frozenset[Scope] - external_identity: ExternalIdentity | None = None - path_params: dict[str, str] = Field(default_factory=dict) - - allowed_roles: frozenset[TenantAccountRole] | None = None - rbac: RBACCheck | None = None - - app: App | None = None - tenant: Tenant | None = None - app_access_mode: WebAppAccessMode | None = None - - tenant_role: TenantAccountRole | None = None - - caller: Account | EndUser | None = None - caller_kind: CallerKind | None = None - - def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]: - if self.app is None or self.caller is None or self.caller_kind is None: - raise InternalServerError("pipeline_invariant_violated: app context missing") - return self.app, self.caller, self.caller_kind diff --git a/api/controllers/openapi/auth/flow.py b/api/controllers/openapi/auth/flow.py deleted file mode 100644 index eee1378cf41..00000000000 --- a/api/controllers/openapi/auth/flow.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -from controllers.openapi.auth.conditions import Cond -from controllers.openapi.auth.data import AuthData, RequestContext - - -class When: - def __init__(self, condition: Cond, *, then: Callable[[Any], None]) -> None: - self.condition = condition - self._step = then - - def applies(self, ctx: RequestContext, data: AuthData | None = None) -> bool: - return self.condition(ctx, data) - - def __call__(self, arg: Any) -> None: - self._step(arg) diff --git a/api/controllers/openapi/auth/loaders.py b/api/controllers/openapi/auth/loaders.py new file mode 100644 index 00000000000..12e5b846be6 --- /dev/null +++ b/api/controllers/openapi/auth/loaders.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import uuid +from enum import StrEnum + +from flask import request +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.openapi.auth.context import Caller, Context, RouteContractError +from models.account import Account, AccountStatus, Tenant, TenantAccountRole, TenantStatus +from models.enums import AppStatus +from models.model import App, EndUser +from services.account_service import TenantService +from services.app_service import AppService + + +class PathParam(StrEnum): + """The route path parameters the auth layer reads.""" + + APP_ID = "app_id" + WORKSPACE_ID = "workspace_id" + FORM_TOKEN = "form_token" + + +def route_has_app(ctx: Context) -> bool: + """Whether the route carries an app at all, read off the path params the + router stored. The store holds what the request resolved; the shape of the + request is a question its readers answer for themselves. + """ + return PathParam.APP_ID in ctx.view_args + + +def load_app(ctx: Context) -> App: + """The boundary where an unset field becomes a value: fetched once per + request, and non-optional from here on, so nothing downstream re-checks. + """ + if ctx._app is None: + ctx._app = _fetch_app(ctx) + return ctx._app + + +def load_workspace(ctx: Context) -> Tenant: + if ctx._workspace is None: + ctx._workspace = _fetch_workspace(ctx) + return ctx._workspace + + +def load_caller(ctx: Context) -> Caller: + if ctx._caller is None: + ctx._caller = ctx.subject.resolve_caller(ctx, ctx.session) + return ctx._caller + + +def load_account(ctx: Context) -> Account: + return _load_caller_as(ctx, Account) + + +def load_end_user(ctx: Context) -> EndUser: + return _load_caller_as(ctx, EndUser) + + +def _load_caller_as[C: Caller](ctx: Context, expected: type[C]) -> C: + caller = load_caller(ctx) + if not isinstance(caller, expected): + raise Forbidden("unsupported_token_type") + return caller + + +def load_workspace_role(ctx: Context) -> TenantAccountRole: + if ctx._workspace_role is None: + ctx._workspace_role = _fetch_workspace_role(ctx) + return ctx._workspace_role + + +def _path_param(ctx: Context, name: str) -> str: + try: + return ctx.view_args[name] + except KeyError: + raise RouteContractError( + f"{name} is not a path parameter of this route: the requirement asking for it does not belong here" + ) + + +def _fetch_app(ctx: Context) -> App: + raw = _path_param(ctx, PathParam.APP_ID) + try: + # Canonical dashed form, so a bare-hex path parameter names the same app. + app_id = str(uuid.UUID(raw)) + except ValueError: + raise NotFound("app not found") + app = AppService.get_app_by_id(app_id, ctx.session) + if not app or app.status != AppStatus.NORMAL: + raise NotFound("app not found") + return app + + +def _fetch_workspace(ctx: Context) -> Tenant: + """Not a check that can be skipped, a source that follows the route: an + app-scoped route takes its workspace from the app, every other one from the + request. Both raise when their own source is absent. + """ + if route_has_app(ctx): + return _workspace_from_app(ctx) + return _workspace_from_request(ctx) + + +def _workspace_from_app(ctx: Context) -> Tenant: + app = load_app(ctx) + tenant = TenantService.get_tenant_by_id(str(app.tenant_id), session=ctx.session) + if tenant is None or tenant.status == TenantStatus.ARCHIVE: + raise Forbidden("workspace unavailable") + return tenant + + +def _workspace_from_request(ctx: Context) -> Tenant: + workspace_id = ctx.view_args.get(PathParam.WORKSPACE_ID) or request.args.get(PathParam.WORKSPACE_ID) + if not workspace_id: + raise NotFound("workspace not found") + try: + uuid.UUID(workspace_id) + except ValueError: + raise NotFound("workspace not found") + tenant = TenantService.get_tenant_by_id(workspace_id, session=ctx.session) + if tenant is None or tenant.status == TenantStatus.ARCHIVE: + raise NotFound("workspace not found") + return tenant + + +def _fetch_workspace_role(ctx: Context) -> TenantAccountRole: + workspace = load_workspace(ctx) + caller = load_caller(ctx) + if not isinstance(caller, Account) or caller.status != AccountStatus.ACTIVE: + raise NotFound("workspace not found") + role = TenantService.get_account_role_in_tenant(str(ctx.subject.account_id), str(workspace.id), session=ctx.session) + if role is None: + raise NotFound("workspace not found") + return role diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py deleted file mode 100644 index bb32927725d..00000000000 --- a/api/controllers/openapi/auth/pipeline.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Auth pipeline — entry point for all openapi auth. - -`PipelineRouter.guard()` is the only attachment point for endpoints. -`AuthPipeline` is a pure step-runner with no routing concerns. -`PipelineRoute` binds a pipeline to optional edition requirements. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from functools import wraps -from typing import Any - -from flask import current_app, request -from flask_login import user_logged_in -from werkzeug.exceptions import Forbidden, NotFound, Unauthorized - -from configs import dify_config -from controllers.common.rbac import RBACCheck -from controllers.openapi._audit import emit_wrong_surface -from controllers.openapi.auth.data import ( - AuthData, - ExternalIdentity, - RequestContext, -) -from controllers.openapi.auth.flow import When -from enums import DeploymentEdition -from libs.oauth_bearer import ( - AuthContext, - Scope, - TokenType, - extract_bearer, - get_authenticator, - reset_auth_ctx, - set_auth_ctx, -) -from models.account import TenantAccountRole -from services.entities.feature_entities import LicenseStatus -from services.system_feature_service import SystemFeatureService - - -class AuthPipeline: - """Pure step-runner — no routing, no guard. - - Both `prepare` and `auth` steps receive the same `AuthData` instance. - `prepare` steps populate it; `auth` steps validate it. - """ - - def __init__(self, prepare: list, auth: list) -> None: - self._prepare = prepare - self._auth = auth - - def _run( - self, - identity: AuthContext, - args: tuple, - kwargs: dict, - view: Callable, - *, - scope: Scope | None, - workspace_membership: bool = False, - allowed_roles: frozenset[TenantAccountRole] | None = None, - rbac: RBACCheck | None = None, - ) -> Any: - req_ctx = RequestContext( - token_type=identity.token_type, - scope=scope, - path_params=dict(request.view_args or {}), - workspace_membership=workspace_membership, - allowed_roles=allowed_roles, - rbac=rbac, - ) - - data = AuthData( - token_type=identity.token_type, - account_id=identity.account_id, - token_id=identity.token_id, - scopes=frozenset(identity.scopes), - required_scope=scope, - allowed_roles=allowed_roles, - rbac=rbac, - path_params=dict(req_ctx.path_params), - external_identity=( - ExternalIdentity(email=identity.subject_email, issuer=identity.subject_issuer) - if identity.subject_email - else None - ), - ) - - for step in self._prepare: - if _should_run(step, req_ctx, data=None): - step(data) - - for step in self._auth: - if _should_run(step, req_ctx, data=data): - step(data) - - reset_token = set_auth_ctx(identity) - if data.caller: - _mount_flask_login(data.caller) - - try: - kwargs["auth_data"] = data - return view(*args, **kwargs) - finally: - reset_auth_ctx(reset_token) - - -@dataclass(frozen=True) -class PipelineRoute: - pipeline: AuthPipeline - required_edition: frozenset[DeploymentEdition] | None = None - - -class PipelineRouter: - """Entry point for openapi auth. - - `guard()` is the decorator that endpoints attach to. It applies - global gates (edition, license, token type) then dispatches to the - matching `PipelineRoute` for the token type. - """ - - def __init__(self, routes: dict[TokenType, PipelineRoute]) -> None: - self._routes = routes - - def guard( - self, - *, - scope: Scope | None = None, - allowed_token_types: frozenset[TokenType] | None = None, - edition: frozenset[DeploymentEdition] | None = None, - require_valid_enterprise_license: bool = False, - workspace_membership: bool = False, - allowed_roles: frozenset[TenantAccountRole] | None = None, - rbac: RBACCheck | None = None, - ) -> Callable: - return self._make_decorator( - scope=scope, - allowed_token_types=allowed_token_types, - edition=edition, - require_valid_enterprise_license=require_valid_enterprise_license, - workspace_membership=workspace_membership, - allowed_roles=allowed_roles, - rbac=rbac, - ) - - def guard_workspace( - self, - *, - scope: Scope | None = None, - allowed_token_types: frozenset[TokenType] | None = None, - edition: frozenset[DeploymentEdition] | None = None, - require_valid_enterprise_license: bool = False, - allowed_roles: frozenset[TenantAccountRole] | None = None, - rbac: RBACCheck | None = None, - ) -> Callable: - return self._make_decorator( - scope=scope, - allowed_token_types=allowed_token_types, - edition=edition, - require_valid_enterprise_license=require_valid_enterprise_license, - workspace_membership=True, - allowed_roles=allowed_roles, - rbac=rbac, - ) - - def _make_decorator( - self, - *, - scope: Scope | None, - allowed_token_types: frozenset[TokenType] | None, - edition: frozenset[DeploymentEdition] | None, - require_valid_enterprise_license: bool, - workspace_membership: bool, - allowed_roles: frozenset[TenantAccountRole] | None, - rbac: RBACCheck | None, - ) -> Callable: - def decorator(view: Callable) -> Callable: - @wraps(view) - def decorated(*args: Any, **kwargs: Any) -> Any: - return self._execute( - args, - kwargs, - view, - scope=scope, - allowed_token_types=allowed_token_types, - edition=edition, - require_valid_enterprise_license=require_valid_enterprise_license, - workspace_membership=workspace_membership, - allowed_roles=allowed_roles, - rbac=rbac, - ) - - return decorated - - return decorator - - def _execute( - self, - args: tuple, - kwargs: dict, - view: Callable, - *, - scope: Scope | None, - allowed_token_types: frozenset[TokenType] | None, - edition: frozenset[DeploymentEdition] | None, - require_valid_enterprise_license: bool, - workspace_membership: bool = False, - allowed_roles: frozenset[TenantAccountRole] | None = None, - rbac: RBACCheck | None = None, - ) -> Any: - # 404 not 403 — this edition doesn't expose the feature at all - if edition is not None and dify_config.DEPLOYMENT_EDITION not in edition: - raise NotFound() - - license_checked = False - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and ( - require_valid_enterprise_license or (edition is not None and DeploymentEdition.ENTERPRISE in edition) - ): - _check_license() - license_checked = True - - token = extract_bearer(request) - if not token: - raise Unauthorized("bearer required") - - identity = get_authenticator().authenticate(token) - - if allowed_token_types is not None and identity.token_type not in allowed_token_types: - emit_wrong_surface( - subject_type=_subject_type_str(identity), - attempted_path=request.path, - client_id=getattr(identity, "client_id", None), - token_id=str(identity.token_id) if identity.token_id else None, - ) - raise Forbidden("unsupported_token_type") - - route = self._routes.get(identity.token_type) - if route is None: - raise Forbidden("unsupported_token_type") - - if route.required_edition is not None: - if dify_config.DEPLOYMENT_EDITION not in route.required_edition: - raise Forbidden("external_sso_requires_ee") - if not license_checked and DeploymentEdition.ENTERPRISE in route.required_edition: - _check_license() - - return route.pipeline._run( - identity, - args, - kwargs, - view, - scope=scope, - workspace_membership=workspace_membership, - allowed_roles=allowed_roles, - rbac=rbac, - ) - - -def _should_run(step: Any, req_ctx: RequestContext, data: AuthData | None) -> bool: - if isinstance(step, When): - return step.applies(req_ctx, data) - return True - - -def _subject_type_str(identity: Any) -> str | None: - subject = getattr(identity, "subject_type", None) - if subject is None: - return None - return subject.value if hasattr(subject, "value") else str(subject) - - -def _check_license() -> None: - if SystemFeatureService.get_license_status() in { - LicenseStatus.INACTIVE, - LicenseStatus.EXPIRED, - LicenseStatus.LOST, - }: - raise Forbidden("license_invalid") - - -def _mount_flask_login(user: Any) -> None: - current_app.login_manager._update_request_context_with_user(user) # type: ignore[attr-defined] - user_logged_in.send(current_app._get_current_object(), user=user) # type: ignore[attr-defined] diff --git a/api/controllers/openapi/auth/pipelines.py b/api/controllers/openapi/auth/pipelines.py new file mode 100644 index 00000000000..32faec7176a --- /dev/null +++ b/api/controllers/openapi/auth/pipelines.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Callable, Generator +from contextlib import contextmanager +from typing import Any, ClassVar, override + +from flask import current_app +from flask_login import user_logged_in +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden + +from configs import dify_config +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.loaders import load_caller +from controllers.openapi.auth.requirements import ( + Rank, + Requirement, + ResolveCaller, +) +from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject +from enums import DeploymentEdition +from libs.oauth_bearer import AuthContext, reset_auth_ctx, set_auth_ctx +from models.account import Account +from models.model import EndUser + +_PIPELINES: dict[type[Subject], Pipeline] = {} + + +class Pipeline: + fixed: ClassVar[tuple[Requirement, ...]] = () + + def __init_subclass__(cls, serves: type[Subject] | None = None, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if serves is not None: + _PIPELINES[serves] = cls() + + def run( + self, + *, + subject: Subject, + auth: AuthContext, + spec: EndpointSpec, + ctx: Context, + session: Session, + call: Callable[..., Any], + ) -> Any: + """Endpoint-declared requirements are merged ahead of the fixed ones, + so a stable sort leaves them first at equal rank — which is what keeps + `CheckSubject` ahead of `_RequiresEnterprise`. + """ + for requirement in sorted(spec.requirements + self.fixed, key=lambda item: item.rank): + requirement.run(subject, ctx, session) + with mounted(subject, auth, ctx): + return call(ctx=ctx) + + +def pipeline_for_subject(subject: Subject) -> Pipeline: + return _PIPELINES[type(subject)] + + +class AccountPipeline(Pipeline, serves=AccountSubject): + fixed = (ResolveCaller(),) + + +class _RequiresEnterprise(Requirement): + """A gate on the token kind, not on a route, so no endpoint declares it. + It runs after `authenticate` on purpose: a `dfoe_` string no row backs + answers 401 like any bad bearer, so the edition cannot be probed. The + licence is the router's, checked before any bearer is read. + """ + + rank = Rank.FIRST + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + raise Forbidden("external_sso_requires_ee") + + +class ExternalSsoPipeline(Pipeline, serves=ExternalSsoSubject): + fixed = ( + _RequiresEnterprise(), + ResolveCaller(), + ) + + +@contextmanager +def mounted(subject: Subject, auth: AuthContext, ctx: Context) -> Generator[None]: + user = load_caller(ctx) if subject.mounts_caller(ctx) else None + reset_token = set_auth_ctx(auth) + try: + if user is not None: + _mount_flask_login(user) + yield + finally: + reset_auth_ctx(reset_token) + + +def _mount_flask_login(user: Account | EndUser) -> None: + current_app.login_manager._update_request_context_with_user(user) # type: ignore[attr-defined] + user_logged_in.send(current_app._get_current_object(), user=user) # type: ignore[attr-defined] diff --git a/api/controllers/openapi/auth/prepare.py b/api/controllers/openapi/auth/prepare.py deleted file mode 100644 index 8cf63fd1c9a..00000000000 --- a/api/controllers/openapi/auth/prepare.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import uuid - -from flask import request -from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized - -from controllers.openapi.auth.data import AuthData, CallerKind -from core.db.session_factory import session_factory -from models.account import AccountStatus, TenantStatus -from models.enums import AppStatus, EndUserType -from services.account_service import AccountService, TenantService -from services.app_service import AppService -from services.end_user_service import EndUserService -from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode - - -def load_app(data: AuthData) -> None: - if data.app is not None: - return - app_id = data.path_params["app_id"] - try: - uuid.UUID(app_id) - except ValueError: - raise NotFound("app not found") - with session_factory.create_session() as session: - app = AppService.get_app_by_id(app_id, session) - if not app or app.status != AppStatus.NORMAL: - raise NotFound("app not found") - data.app = app - - -def load_tenant(data: AuthData) -> None: - if data.tenant is not None: - return - if data.app is None: - raise InternalServerError("pipeline_invariant_violated: app not loaded before load_tenant") - with session_factory.create_session() as session: - tenant = TenantService.get_tenant_by_id(str(data.app.tenant_id), session=session) - if tenant is None or tenant.status == TenantStatus.ARCHIVE: - raise Forbidden("workspace unavailable") - data.tenant = tenant - - -def load_tenant_from_request(data: AuthData) -> None: - if data.tenant is not None: - return - workspace_id = data.path_params.get("workspace_id") or request.args.get("workspace_id") - if not workspace_id: - raise NotFound("workspace not found") - try: - uuid.UUID(workspace_id) - except ValueError: - raise NotFound("workspace not found") - with session_factory.create_session() as session: - tenant = TenantService.get_tenant_by_id(workspace_id, session=session) - if tenant is None or tenant.status == TenantStatus.ARCHIVE: - raise NotFound("workspace not found") - data.tenant = tenant - - -def load_account(data: AuthData) -> None: - if data.caller is not None: - return - with session_factory.create_session() as session: - account = AccountService.get_account_by_id(str(data.account_id), session=session) - if account is None: - raise Unauthorized("account not found") - if data.tenant: - account.set_current_tenant_with_session(data.tenant, session=session) - data.caller = account - data.caller_kind = CallerKind.ACCOUNT - - -def load_workspace_role(data: AuthData) -> None: - if data.tenant_role is not None: - return - if data.tenant is None or data.account_id is None: - return - if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE: - return - with session_factory.create_session() as session: - role = TenantService.get_account_role_in_tenant(str(data.account_id), str(data.tenant.id), session=session) - if role is None: - return - data.tenant_role = role - - -def resolve_external_user(data: AuthData) -> None: - if data.tenant is None or data.app is None or data.external_identity is None: - raise Unauthorized("missing context for external user resolution") - end_user = EndUserService.get_or_create_end_user_by_type( - EndUserType.OPENAPI, - tenant_id=str(data.tenant.id), - app_id=str(data.app.id), - user_id=data.external_identity.email, - ) - data.caller = end_user - data.caller_kind = CallerKind.END_USER - - -def load_app_access_mode(data: AuthData) -> None: - if data.app is None: - return - try: - settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(data.app.id)) - if settings is None: - data.app_access_mode = None - return - data.app_access_mode = WebAppAccessMode(settings.access_mode) - except ValueError: - data.app_access_mode = None diff --git a/api/controllers/openapi/auth/requirements.py b/api/controllers/openapi/auth/requirements.py new file mode 100644 index 00000000000..a6446cadabd --- /dev/null +++ b/api/controllers/openapi/auth/requirements.py @@ -0,0 +1,208 @@ +"""Self-contained authorization requirements. + +Requirements are process-lifetime singletons: built once at import, shared by +every request and every thread. Config belongs in `__init__`, and `run` must +neither cache nor mutate — a cache here would outlive the fact it recorded. +Per-request caching belongs in `loaders.py`, which stores into `Context`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from enum import IntEnum +from typing import ClassVar, override + +from flask import request +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden + +from configs import dify_config +from controllers.common.rbac import RBACCheck, enforce_rbac_checks +from controllers.openapi._audit import emit_wrong_surface +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role +from controllers.openapi.auth.subjects import Subject +from enums import DeploymentEdition +from libs.oauth_bearer import Scope +from models.account import TenantAccountRole +from models.enums import CreatorUserRole +from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode +from services.entities.feature_entities import LicenseStatus +from services.system_feature_service import SystemFeatureService + +_DEAD_LICENSE_STATUSES = frozenset({LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST}) + + +class Rank(IntEnum): + """Three bands, coarsest first. Ties fall back to declaration order — + endpoint-declared ahead of pipeline-fixed — so `Pipeline.run`'s sort stays + stable rather than needing every requirement in its own band. + """ + + FIRST = 0 # reject the caller before anything touches data + EARLY = 10 # must precede permission checks + NORMAL = 20 # default - declared order decides + + +class Requirement(ABC): + rank: ClassVar[Rank] = Rank.NORMAL + + @abstractmethod + def run(self, subject: Subject, ctx: Context, session: Session) -> None: ... + + +class CheckSubject(Requirement): + rank = Rank.FIRST + + def __init__(self, *, allowed: Sequence[type[Subject]]) -> None: + self.allowed = tuple(allowed) + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if isinstance(subject, self.allowed): + return + emit_wrong_surface( + subject_type=subject.subject_type.value, + attempted_path=request.path, + client_id=subject.client_id, + token_id=str(subject.token_id) if subject.token_id else None, + ) + raise Forbidden("unsupported_token_type") + + +def assert_license_valid() -> None: + """The router's deployment-wide gate, answered before `extract_bearer`.""" + if SystemFeatureService.get_public_system_features().license.status in _DEAD_LICENSE_STATUSES: + raise Forbidden("license_invalid") + + +class CheckAppApiEnabled(Requirement): + rank = Rank.EARLY + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if not load_app(ctx).enable_api: + raise Forbidden("service_api_disabled") + + +class CheckWorkspaceMember(Requirement): + """Resolving the role *is* the check: `load_workspace_role` 404s a non-member. + + Which workspace that is follows from the route — the app's on an app-scoped + one, the path or query parameter otherwise — so this one requirement serves + both. It cannot be inferred and left implicit: `GET /apps` takes its + workspace from the query string, and `GET /workspaces/` has + the path parameter but gets no membership check. + """ + + rank = Rank.EARLY + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if subject.caller_role is not CreatorUserRole.ACCOUNT: + return + load_workspace_role(ctx) + + +class CheckScope(Requirement): + def __init__(self, scope: Scope) -> None: + self.scope = scope + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if Scope.FULL in subject.scopes or self.scope in subject.scopes: + return + raise Forbidden("insufficient_scope") + + +class CheckRBACPermission(Requirement): + """The same check bundles the console's `rbac_permission_required` takes. + Inert wherever RBAC is off; a route that needs a check there declares a + `CheckWorkspaceRole` beside this. + """ + + def __init__(self, *checks: RBACCheck) -> None: + if not checks: + raise ValueError("CheckRBACPermission requires at least one RBACCheck") + self.checks = checks + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if subject.caller_role is not CreatorUserRole.ACCOUNT: + return + if not dify_config.RBAC_ENABLED: + return + enforce_rbac_checks( + tenant_id=str(load_workspace(ctx).id), + account_id=str(subject.account_id), + checks=self.checks, + path_args=dict(ctx.view_args), + ) + + +class CheckWorkspaceRole(Requirement): + """The workspace-role gate that predates RBAC. Inert wherever RBAC is on; + a route that needs a check there declares a `CheckRBACPermission` beside this. + """ + + def __init__(self, allowed_roles: frozenset[TenantAccountRole]) -> None: + self.allowed_roles = allowed_roles + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if subject.caller_role is not CreatorUserRole.ACCOUNT: + return + if dify_config.RBAC_ENABLED: + return + if load_workspace_role(ctx) not in self.allowed_roles: + raise Forbidden("insufficient workspace role") + + +class CheckAppAccess(Requirement): + """Run-scope comes from the declaration site, so it is not re-checked here. + + The ACL is gated on `webapp_auth.enabled` and the private-app check is not: + the asymmetry is deliberate, not an oversight. + """ + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: + return + access_mode = self._access_mode(str(load_app(ctx).id)) + if SystemFeatureService.get_public_system_features().webapp_auth.enabled: + self._assert_mode_allowed(subject, access_mode) + if access_mode == WebAppAccessMode.PRIVATE: + self._assert_private_app_permission(subject, ctx, session) + + def _access_mode(self, app_id: str) -> WebAppAccessMode | None: + try: + settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id) + if settings is None: + return None + return WebAppAccessMode(settings.access_mode) + except ValueError: + return None + + def _assert_mode_allowed(self, subject: Subject, access_mode: WebAppAccessMode | None) -> None: + if access_mode is None: + raise Forbidden("app or access mode not loaded") + if access_mode not in subject.webapp_modes: + raise Forbidden("subject_not_allowed_for_access_mode") + + def _assert_private_app_permission(self, subject: Subject, ctx: Context, session: Session) -> None: + user_id = subject.webapp_user_id(session) + if user_id is None: + raise Forbidden("cannot resolve user for private app check") + app_id = load_app(ctx).id + if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id=user_id, app_id=app_id): + raise Forbidden("user_not_allowed_for_private_app") + + +class ResolveCaller(Requirement): + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + if not subject.mounts_caller(ctx): + return + load_caller(ctx) diff --git a/api/controllers/openapi/auth/router.py b/api/controllers/openapi/auth/router.py new file mode 100644 index 00000000000..a2c3714f41d --- /dev/null +++ b/api/controllers/openapi/auth/router.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Callable +from functools import partial, wraps +from typing import Any + +from flask import request +from werkzeug.exceptions import NotFound, Unauthorized + +from configs import dify_config +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.pipelines import pipeline_for_subject +from controllers.openapi.auth.requirements import assert_license_valid +from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.subjects import subject_from_auth +from core.db.session_factory import session_factory +from enums import DeploymentEdition +from libs.oauth_bearer import InvalidBearerError, assert_bearer_feature_enabled, extract_bearer, get_authenticator + + +class AuthRouter: + def guard(self, spec: EndpointSpec) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + @wraps(view) + def decorated(*args: Any, **kwargs: Any) -> Any: + return self._execute(spec, partial(view, *args, **kwargs)) + + return decorated + + return decorator + + def _execute(self, spec: EndpointSpec, call: Callable[..., Any]) -> Any: + """The order is the contract. An endpoint the edition does not expose + answers 404 before anything reveals whether the bearer was valid, and + an enterprise deployment's licence answers 403 before the missing-bearer + 401. The licence is a fact about the deployment, not the route or the + caller, so this is the one place it is checked. The bearer feature flag + is the same kind of fact and answers 503 next. + """ + if spec.edition is not None and dify_config.DEPLOYMENT_EDITION not in spec.edition: + raise NotFound() + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: + assert_license_valid() + assert_bearer_feature_enabled() + + token = extract_bearer(request) + if not token: + raise Unauthorized("bearer required") + + try: + auth = get_authenticator().authenticate(token) + except InvalidBearerError: + # One answer for every rejection reason - unknown prefix, no live row, + # expired - so a caller cannot probe which one it hit. Same reasoning as + # the 404-not-403 elsewhere on this surface. + raise Unauthorized("invalid bearer") + subject = subject_from_auth(auth) + pipeline = pipeline_for_subject(subject) + + # One session spans the loaders and the handler, so the objects the + # requirements load stay attached while the handler reads them. It is + # finalised here, like `with_session` does, until repositories own it. + with session_factory.create_session() as session: + ctx = Context(subject, session, dict(request.view_args or {})) + try: + result = pipeline.run( + subject=subject, + auth=auth, + spec=spec, + ctx=ctx, + session=session, + call=call, + ) + except Exception: + session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- the router owns the rollback + raise + session.commit() + return result + + +subject_router = AuthRouter() diff --git a/api/controllers/openapi/auth/spec.py b/api/controllers/openapi/auth/spec.py new file mode 100644 index 00000000000..1fa69fd482e --- /dev/null +++ b/api/controllers/openapi/auth/spec.py @@ -0,0 +1,23 @@ +"""`@endpoint` builds one at decoration time and attaches it as `view.__spec__`; +the router reads nothing else off the view. It lives in `auth/` so the +dependency runs `_contract.py` -> `auth/` and never back. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from controllers.openapi.auth.requirements import Requirement +from enums import DeploymentEdition + + +@dataclass(frozen=True, slots=True) +class EndpointSpec: + """`edition` is the endpoint-level gate — a 404 raised before any bearer + is read, because the route is not exposed on this edition at all. It is + not `ExternalSsoPipeline`'s own gate, which 403s a token kind after + authentication. + """ + + requirements: tuple[Requirement, ...] + edition: frozenset[DeploymentEdition] | None = None diff --git a/api/controllers/openapi/auth/subjects.py b/api/controllers/openapi/auth/subjects.py new file mode 100644 index 00000000000..2bac031e0bb --- /dev/null +++ b/api/controllers/openapi/auth/subjects.py @@ -0,0 +1,150 @@ +"""`TokenType` in `libs/oauth_bearer` owns the mint-time facts (prefix, subject, +scopes); a `Subject` owns the request-time behaviour, so `libs/` never has to +import the auth layer. +""" + +from __future__ import annotations + +import uuid +from abc import ABC, abstractmethod +from typing import ClassVar, override + +from sqlalchemy.orm import Session +from werkzeug.exceptions import Unauthorized + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.data import ExternalIdentity +from controllers.openapi.auth.loaders import load_app, load_workspace, route_has_app +from libs.oauth_bearer import AuthContext, Scope, SubjectType +from models.account import Account +from models.enums import CreatorUserRole, EndUserType +from models.model import EndUser +from services.account_service import AccountService +from services.end_user_service import EndUserService +from services.enterprise.enterprise_service import WebAppAccessMode + +_SUBJECT_CLASSES: dict[SubjectType, type[Subject]] = {} + + +class Subject(ABC): + subject_type: ClassVar[SubjectType] + caller_role: ClassVar[CreatorUserRole] + webapp_modes: ClassVar[frozenset[WebAppAccessMode]] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + _SUBJECT_CLASSES[cls.subject_type] = cls + + def __init__(self, auth: AuthContext) -> None: + self._auth = auth + + @property + def auth(self) -> AuthContext: + return self._auth + + @property + def account_id(self) -> uuid.UUID | None: + return self._auth.account_id + + @property + def client_id(self) -> str | None: + return self._auth.client_id + + @property + def token_id(self) -> uuid.UUID: + return self._auth.token_id + + @property + def scopes(self) -> frozenset[Scope]: + return self._auth.scopes + + @abstractmethod + def resolve_caller(self, ctx: Context, session: Session) -> Account | EndUser: + """Loads whatever workspace or app it needs itself, so a subject that + lands on a route carrying neither fails where the requirement is, + rather than silently resolving a caller bound to nothing. + """ + + @abstractmethod + def mounts_caller(self, ctx: Context) -> bool: ... + + @abstractmethod + def webapp_user_id(self, session: Session) -> str | None: ... + + +class AccountSubject(Subject): + subject_type = SubjectType.ACCOUNT + caller_role = CreatorUserRole.ACCOUNT + webapp_modes = frozenset( + { + WebAppAccessMode.PUBLIC, + WebAppAccessMode.SSO_VERIFIED, + WebAppAccessMode.PRIVATE_ALL, + WebAppAccessMode.PRIVATE, + } + ) + + @override + def resolve_caller(self, ctx: Context, session: Session) -> Account: + account = AccountService.get_account_by_id(str(self.account_id), session=session) + if account is None: + raise Unauthorized("account not found") + if ctx._workspace is not None: + account.set_current_tenant_with_session(ctx._workspace, session=session) + return account + + @override + def mounts_caller(self, ctx: Context) -> bool: + return True + + @override + def webapp_user_id(self, session: Session) -> str | None: + return str(self.account_id) if self.account_id is not None else None + + +class ExternalSsoSubject(Subject): + subject_type = SubjectType.EXTERNAL_SSO + caller_role = CreatorUserRole.END_USER + webapp_modes = frozenset( + { + WebAppAccessMode.PUBLIC, + WebAppAccessMode.SSO_VERIFIED, + } + ) + + @property + def external_identity(self) -> ExternalIdentity | None: + if not self._auth.subject_email: + return None + return ExternalIdentity(email=self._auth.subject_email, issuer=self._auth.subject_issuer) + + @override + def resolve_caller(self, ctx: Context, session: Session) -> EndUser: + identity = self.external_identity + if identity is None: + raise Unauthorized("missing context for external user resolution") + return EndUserService.get_or_create_end_user_by_type( + EndUserType.OPENAPI, + tenant_id=str(load_workspace(ctx).id), + app_id=str(load_app(ctx).id), + user_id=identity.email, + ) + + @override + def mounts_caller(self, ctx: Context) -> bool: + """An external caller is an end user *of an app*; off an app-scoped + route it has no caller to resolve at all. + """ + return route_has_app(ctx) + + @override + def webapp_user_id(self, session: Session) -> str | None: + identity = self.external_identity + if identity is None: + return None + account = AccountService.get_account_by_email(identity.email, session=session) + return str(account.id) if account is not None else None + + +def subject_from_auth(auth: AuthContext) -> Subject: + return _SUBJECT_CLASSES[auth.subject_type](auth) diff --git a/api/controllers/openapi/auth/verify.py b/api/controllers/openapi/auth/verify.py deleted file mode 100644 index c669bfaecd9..00000000000 --- a/api/controllers/openapi/auth/verify.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import annotations - -from flask import request -from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity - -from configs import dify_config -from controllers.common.rbac import enforce_rbac_checks -from controllers.openapi.auth.data import AuthData, CallerKind -from extensions.ext_database import db -from libs.oauth_bearer import Scope, TokenType -from services.account_service import AccountService -from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode - - -def check_scope(data: AuthData) -> None: - if data.required_scope is None: - return - if Scope.FULL in data.scopes or data.required_scope in data.scopes: - return - raise Forbidden("insufficient_scope") - - -def check_workspace_member(data: AuthData) -> None: - """Assert the caller belongs to the resolved tenant. - - `load_workspace_role` stashes the membership role (None when the caller is - not a member or is inactive). A missing membership surfaces as 404, not - 403, so workspace IDs don't leak across tenants. - """ - if data.tenant_role is None: - raise NotFound("workspace not found") - - -def check_workspace_mismatch(data: AuthData) -> None: - if data.tenant is None: - return - request_workspace_id = data.path_params.get("workspace_id") or request.args.get("workspace_id") - if request_workspace_id and request_workspace_id != str(data.tenant.id): - raise UnprocessableEntity("workspace_id does not match app's workspace") - - -def check_workspace_role(data: AuthData) -> None: - if dify_config.RBAC_ENABLED and data.rbac is not None: - # fine-grained permission check is performed by RBAC - return - if data.allowed_roles is None: - return - if data.tenant_role is None: - raise NotFound("workspace not found") - if data.tenant_role not in data.allowed_roles: - raise Forbidden("insufficient workspace role") - - -def check_rbac_permission(data: AuthData) -> None: - req = data.rbac - if req is None: - return - if not dify_config.RBAC_ENABLED: - return - # Only account callers are subject to RBAC; end_user access is scope-controlled. - if data.caller_kind != CallerKind.ACCOUNT: - return - if data.account_id is None or data.tenant is None: - raise Forbidden("rbac context missing") - enforce_rbac_checks( - tenant_id=str(data.tenant.id), - account_id=str(data.account_id), - checks=[req], - path_args=dict(data.path_params), - ) - - -def check_app_api_enabled(data: AuthData) -> None: - if data.app is None: - return - if not data.app.enable_api: - raise Forbidden("service_api_disabled") - - -_ALLOWED_MODES_BY_TOKEN_TYPE: dict[TokenType, frozenset[WebAppAccessMode]] = { - TokenType.OAUTH_ACCOUNT: frozenset( - { - WebAppAccessMode.PUBLIC, - WebAppAccessMode.SSO_VERIFIED, - WebAppAccessMode.PRIVATE_ALL, - WebAppAccessMode.PRIVATE, - } - ), - TokenType.OAUTH_EXTERNAL_SSO: frozenset( - { - WebAppAccessMode.PUBLIC, - WebAppAccessMode.SSO_VERIFIED, - } - ), -} - - -def check_acl(data: AuthData) -> None: - if data.app is None or data.app_access_mode is None: - raise Forbidden("app or access mode not loaded") - allowed_modes = _ALLOWED_MODES_BY_TOKEN_TYPE.get(data.token_type, frozenset()) - if data.app_access_mode not in allowed_modes: - raise Forbidden("subject_not_allowed_for_access_mode") - - -def check_private_app_permission(data: AuthData) -> None: - if data.app is None: - raise Forbidden("app not loaded") - user_id = _resolve_user_id(data) - if user_id is None: - raise Forbidden("cannot resolve user for private app check") - if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id=user_id, app_id=data.app.id): - raise Forbidden("user_not_allowed_for_private_app") - - -def _resolve_user_id(data: AuthData) -> str | None: - if data.token_type == TokenType.OAUTH_ACCOUNT: - return str(data.account_id) if data.account_id is not None else None - if data.external_identity is None: - return None - account = AccountService.get_account_by_email(data.external_identity.email, session=db.session()) - return str(account.id) if account is not None else None diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py index ab9e3455bb3..53bed421ca6 100644 --- a/api/controllers/openapi/files.py +++ b/api/controllers/openapi/files.py @@ -4,7 +4,6 @@ from __future__ import annotations from flask import request from flask_restx import Resource -from flask_restx.api import HTTPStatus from werkzeug.exceptions import BadRequest import services @@ -16,10 +15,17 @@ from controllers.common.errors import ( UnsupportedFileTypeError, ) from controllers.openapi import openapi_ns -from controllers.openapi._contract import returns +from controllers.openapi._contract import endpoint from controllers.openapi._errors import FilenameNotExists -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckScope, + CheckSubject, + CheckWorkspaceMember, +) +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject from extensions.ext_application_services import application_services from fields.file_fields import FileResponse from libs.oauth_bearer import Scope @@ -38,10 +44,17 @@ class AppFileUploadApi(Resource): 415: "Unsupported file type", } ) - @auth_router.guard(scope=Scope.APPS_RUN) - @returns(HTTPStatus.CREATED, FileResponse, description="File uploaded") - def post(self, app_id: str, *, auth_data: AuthData) -> FileResponse: - _app_model, caller, _caller_kind = auth_data.require_app_context() + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckAppAccess(), + ), + returns=(201, FileResponse, "File uploaded"), + ) + def post(self, ctx: Context, app_id: str): if "file" not in request.files: raise NoFileUploadedError() if len(request.files) > 1: @@ -58,7 +71,7 @@ class AppFileUploadApi(Resource): filename=file.filename, content=file.stream.read(), mimetype=file.mimetype, - user=caller, + user=ctx.caller, ) except services.errors.file.FileTooLargeError as exc: raise FileTooLargeError(exc.description) from exc diff --git a/api/controllers/openapi/flask_admission.py b/api/controllers/openapi/flask_admission.py deleted file mode 100644 index 7e485c70db6..00000000000 --- a/api/controllers/openapi/flask_admission.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Flask adapter for account-authenticated OpenAPI admission.""" - -from __future__ import annotations - -from collections.abc import Callable -from functools import wraps -from typing import Concatenate - -from flask import Response, request -from werkzeug.exceptions import Unauthorized - -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData -from core.logging.context import get_request_id, get_trace_id -from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType -from libs.rate_limit import RateLimit, enforce -from machinery.context import AccountRequestContext -from models.account import Account, AccountStatus - - -def openapi_account_admission[T, **P, R]( - *, - scope: Scope, - editions: frozenset[DeploymentEdition] | None = None, - require_initialized: bool = True, - require_valid_enterprise_license: bool = True, - rate_limit: RateLimit | None = None, -) -> Callable[ - [Callable[Concatenate[T, AccountRequestContext, P], R]], - Callable[Concatenate[T, P], R | Response], -]: - """Authenticate an account bearer and inject framework-neutral identity. - - Client-version admission remains attached to the OpenAPI blueprint so it - can also reject requests for removed routes. Edition and Enterprise - license checks are delegated to the shared auth router before the stable - context is constructed. - """ - - def decorator( - view: Callable[Concatenate[T, AccountRequestContext, P], R], - ) -> Callable[Concatenate[T, P], R | Response]: - @wraps(view) - def inject_request_context( - self: T, - /, - *args: P.args, - **kwargs: P.kwargs, - ) -> R: - auth_data = kwargs.pop("auth_data", None) - if not isinstance(auth_data, AuthData): - raise RuntimeError("OpenAPI auth pipeline did not provide valid AuthData") - account = auth_data.caller - if not isinstance(account, Account) or auth_data.account_id is None: - raise Unauthorized("account not found") - if require_initialized and account.status == AccountStatus.UNINITIALIZED: - raise Unauthorized("account not initialized") - - account_id = str(auth_data.account_id) - if rate_limit is not None: - enforce(rate_limit, key=f"account:{account_id}") - - context = AccountRequestContext( - request_id=get_request_id(), - trace_id=get_trace_id() or request.headers.get("X-Trace-Id"), - account_id=account_id, - access_token_id=str(auth_data.token_id) if auth_data.token_id is not None else None, - ) - return view(self, context, *args, **kwargs) - - authenticated = auth_router.guard( - scope=scope, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - edition=editions, - require_valid_enterprise_license=require_valid_enterprise_license, - )(inject_request_context) - - # Keep one stable test seam: one ``__wrapped__`` skips route admission - # and reaches input parsing/response handling. Client-version admission - # stays blueprint-wide by design. - @wraps(view) - def admitted(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response: - return authenticated(self, *args, **kwargs) - - return admitted - - return decorator diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index af3fcb8d85f..58eb3c80ce3 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -1,35 +1,39 @@ -""" -OpenAPI bearer-authed human input form endpoints. - -GET /apps//human-input-forms/ — fetch paused form definition -POST /apps//human-input-forms/:submit — submit form response -""" - from __future__ import annotations import json import logging +from typing import override from flask import Response from flask_restx import Resource +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission from controllers.common.schema import register_schema_models from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, CallerKind -from core.workflow.human_input_policy import ( - HumanInputSurface, - is_recipient_type_allowed_for_surface, +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.loaders import PathParam, load_app +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, + Requirement, ) +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject +from core.db.session_factory import session_factory +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db from libs.helper import to_timestamp from libs.oauth_bearer import Scope +from models.enums import CreatorUserRole from models.model import App from services.human_input_service import FormNotFoundError, HumanInputService @@ -38,6 +42,30 @@ logger = logging.getLogger(__name__) register_schema_models(openapi_ns, HumanInputFormSubmitPayload) +class CheckFormSurface(Requirement): + """`/openapi/v1` is allowed only the recipient types `human_input_policy` lists + for it, so a console-bound form is refused before any handler body runs. It + lives here rather than in `auth/requirements.py` so the auth layer never has + to import this feature's domain. + + A form the caller could not have found anyway — missing, or belonging to + another app — is left to the handler's own 404: answering 403 here would tell + an outsider the form token exists. + """ + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + form_token = ctx.view_args[PathParam.FORM_TOKEN] + app = load_app(ctx) + # `HumanInputService` takes an engine or a maker, never a `Session`, so this + # read is a separate transaction from the handler's — as it was before the move. + form = HumanInputService(session_factory.get_session_maker()).get_form_by_token(form_token) + if form is None or form.app_id != app.id or form.tenant_id != app.tenant_id: + return + if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI): + raise RecipientSurfaceMismatch() + + def _jsonify_form_definition(form) -> Response: definition_payload = form.get_definition().model_dump() payload = { @@ -55,56 +83,60 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None: raise HumanInputFormNotFound() -def _ensure_form_is_allowed_for_openapi(form) -> None: - if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI): - raise RecipientSurfaceMismatch() - - @openapi_ns.route("/apps//human-input-forms/") class OpenApiWorkflowHumanInputFormApi(Resource): - @openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__]) - @auth_router.guard( - scope=Scope.APPS_RUN, - rbac=RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), + CheckFormSurface(), + ), + returns=(200, HumanInputFormDefinitionResponse, "Form definition"), ) - def get(self, app_id: str, form_token: str, *, auth_data: AuthData): - app_model, _caller, _caller_kind = auth_data.require_app_context() + def get(self, ctx: Context, app_id: str, form_token: str): service = HumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: raise HumanInputFormNotFound() - _ensure_form_belongs_to_app(form, app_model) - _ensure_form_is_allowed_for_openapi(form) + _ensure_form_belongs_to_app(form, ctx.app) service.ensure_form_active(form) return _jsonify_form_definition(form) @openapi_ns.route("/apps//human-input-forms/:submit") class OpenApiWorkflowHumanInputFormSubmitApi(Resource): - @auth_router.guard( - scope=Scope.APPS_RUN, - rbac=RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), + CheckFormSurface(), + ), + body=HumanInputFormSubmitPayload, + returns=(200, FormSubmitResponse, "Form submitted"), ) - @returns(200, FormSubmitResponse, description="Form submitted") - @accepts(body=HumanInputFormSubmitPayload) - def post(self, app_id: str, form_token: str, *, auth_data: AuthData, body: HumanInputFormSubmitPayload): - app_model, caller, caller_kind = auth_data.require_app_context() - + def post(self, ctx: Context, app_id: str, form_token: str, *, body: HumanInputFormSubmitPayload): service = HumanInputService(db.engine) form = service.get_form_by_token(form_token) if form is None: raise HumanInputFormNotFound() - _ensure_form_belongs_to_app(form, app_model) - _ensure_form_is_allowed_for_openapi(form) + _ensure_form_belongs_to_app(form, ctx.app) submission_user_id: str | None = None submission_end_user_id: str | None = None - if caller_kind == CallerKind.ACCOUNT: - submission_user_id = caller.id + if ctx.subject.caller_role is CreatorUserRole.ACCOUNT: + submission_user_id = ctx.account.id else: - submission_end_user_id = caller.id + submission_end_user_id = ctx.end_user.id if form.recipient_type is None: logger.warning("Recipient type is None for form, form_token=%s", form_token) diff --git a/api/controllers/openapi/oauth_device.py b/api/controllers/openapi/oauth_device.py index 3ba5f2ee207..2c418150fe3 100644 --- a/api/controllers/openapi/oauth_device.py +++ b/api/controllers/openapi/oauth_device.py @@ -48,7 +48,7 @@ from controllers.openapi._models import ( from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.helper import extract_remote_ip -from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType, bearer_feature_required +from libs.oauth_bearer import SubjectType, TokenType, bearer_feature_required from libs.rate_limit import ( LIMIT_DEVICE_CODE_PER_IP, LIMIT_DEVICE_FLOW_APPROVE, @@ -70,7 +70,6 @@ from services.oauth_device_flow import ( mint_oauth_token, oauth_ttl_days, ) -from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy logger = logging.getLogger(__name__) @@ -236,15 +235,6 @@ class DeviceApproveApi(Resource): return {"error": "approve_in_progress"}, 409 try: - profile = MINTABLE_PROFILES[SubjectType.ACCOUNT] - try: - validate_mint_policy( - subject_type=profile.subject_type, - prefix=profile.prefix, - scopes=profile.scopes, - ) - except MintPolicyViolation as e: - raise BadRequest(description=str(e)) from None ttl_days = oauth_ttl_days(tenant_id=tenant) mint = mint_oauth_token( redis_client, @@ -253,7 +243,7 @@ class DeviceApproveApi(Resource): account_id=str(account.id), client_id=state.client_id, device_label=state.device_label, - prefix=profile.prefix, + token_type=TokenType.OAUTH_ACCOUNT, ttl_days=ttl_days, session=db.session(), ) diff --git a/api/controllers/openapi/oauth_device_sso.py b/api/controllers/openapi/oauth_device_sso.py index fbf7bfa6295..99519fa1890 100644 --- a/api/controllers/openapi/oauth_device_sso.py +++ b/api/controllers/openapi/oauth_device_sso.py @@ -46,7 +46,7 @@ from libs.device_flow_security import ( mint_approval_grant, verify_approval_grant, ) -from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType +from libs.oauth_bearer import SubjectType, TokenType from libs.rate_limit import ( LIMIT_APPROVE_EXT_PER_EMAIL, LIMIT_SSO_INITIATE_PER_IP, @@ -64,7 +64,6 @@ from services.oauth_device_flow import ( mint_oauth_token, oauth_ttl_days, ) -from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy logger = logging.getLogger(__name__) @@ -281,16 +280,6 @@ def approve_external(): if not consume_approval_grant_nonce(redis_client, claims.nonce): raise Unauthorized("session_already_consumed") - profile = MINTABLE_PROFILES[SubjectType.EXTERNAL_SSO] - try: - validate_mint_policy( - subject_type=profile.subject_type, - prefix=profile.prefix, - scopes=profile.scopes, - ) - except MintPolicyViolation as e: - raise BadRequest(description=str(e)) from None - ttl_days = oauth_ttl_days(tenant_id=None) mint = mint_oauth_token( redis_client, @@ -299,7 +288,7 @@ def approve_external(): account_id=None, client_id=state.client_id, device_label=state.device_label, - prefix=profile.prefix, + token_type=TokenType.OAUTH_EXTERNAL_SSO, ttl_days=ttl_days, session=db.session(), ) diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index 5e54c301fba..67286a820a7 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -14,25 +14,32 @@ from collections.abc import Generator from flask import Response, request from flask_restx import Resource from pydantic import BaseModel, Field -from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound, UnprocessableEntity from controllers.common.fields import EventStreamResponse from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission from controllers.common.schema import query_params_from_model from controllers.openapi import openapi_ns -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, CallerKind +from controllers.openapi._contract import endpoint +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, +) +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.apps.message_generator import MessageGenerator from core.app.apps.workflow.app_generator import WorkflowAppGenerator from core.app.entities.task_entities import StreamEvent +from core.db.session_factory import session_factory from core.workflow.human_input_policy import HumanInputSurface -from extensions.ext_database import db from libs.oauth_bearer import Scope -from models.enums import CreatorUserRole from models.model import AppMode from repositories.factory import DifyAPIRepositoryFactory from services.workflow_event_snapshot_service import build_workflow_event_stream @@ -46,18 +53,31 @@ class WorkflowEventsQuery(BaseModel): @openapi_ns.route("/apps//tasks//events") class OpenApiWorkflowEventsApi(Resource): @openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery)) - @openapi_ns.response(200, "SSE event stream", openapi_ns.models[EventStreamResponse.__name__]) - @auth_router.guard( - scope=Scope.APPS_RUN, - rbac=RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), + ), + returns=(200, EventStreamResponse, "SSE event stream"), ) - def get(self, app_id: str, task_id: str, *, auth_data: AuthData): - app_model, caller, caller_kind = auth_data.require_app_context() + def get(self, ctx: Context, app_id: str, task_id: str): + # The router's session closes as soon as this returns, so everything the SSE + # body needs is read off `ctx` here and the generators below close over plain + # values only. + app_model = ctx.app + caller = ctx.caller + app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}: raise UnprocessableEntity("mode_not_supported_for_event_reconnect") - session_maker = sessionmaker(db.engine) + # The event stream outlives `ctx.session`, so this route needs a maker of its + # own — the guard's, not a fresh one bound straight to the engine. + session_maker = session_factory.get_session_maker() repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) workflow_run = repo.get_workflow_run_by_id_and_tenant_id( tenant_id=app_model.tenant_id, @@ -70,14 +90,14 @@ class OpenApiWorkflowEventsApi(Resource): if workflow_run.app_id != app_model.id: raise NotFound("Workflow run not found") - if caller_kind == CallerKind.ACCOUNT: - if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id: - raise NotFound("Workflow run not found") - else: - if workflow_run.created_by_role != CreatorUserRole.END_USER or workflow_run.created_by != caller.id: - raise NotFound("Workflow run not found") + # Ownership is a property of the run row, not of the app, so no pipeline + # requirement can answer it — the caller may only reconnect to its own run. + if workflow_run.created_by_role != ctx.subject.caller_role or workflow_run.created_by != caller.id: + raise NotFound("Workflow run not found") workflow_run_entity = workflow_run + tenant_id = app_model.tenant_id + owning_app_id = app_model.id if workflow_run_entity.finished_at is not None: response = WorkflowResponseConverter.workflow_run_result_to_finish_response( @@ -110,8 +130,8 @@ class OpenApiWorkflowEventsApi(Resource): build_workflow_event_stream( app_mode=app_mode, workflow_run=workflow_run_entity, - tenant_id=app_model.tenant_id, - app_id=app_model.id, + tenant_id=tenant_id, + app_id=owning_app_id, session_maker=session_maker, human_input_surface=HumanInputSurface.OPENAPI, close_on_pause=not continue_on_pause, diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 6053e09bf28..c2436a5a3c6 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -5,8 +5,9 @@ endpoints. Account bearers (dfoa_) see every tenant they're a member of. External SSO bearers (dfoe_) have no account_id and so see an empty list — that matches /openapi/v1/account. -Member-management endpoints use ``guard_workspace`` which enforces -workspace membership and optional role requirements via the auth pipeline. +Member management declares both authorization arms; ``RBAC_ENABLED`` picks one. +``GET /workspaces/`` deliberately declares neither: it admits any +account bearer and lets the view's own membership-scoped lookup answer 404. """ from __future__ import annotations @@ -15,13 +16,12 @@ from itertools import starmap from urllib import parse from flask_restx import Resource -from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound from configs import dify_config -from controllers.common.session import with_session +from controllers.common.rbac import RBACCheck, RBACPermission, Workspace from controllers.openapi import openapi_ns -from controllers.openapi._contract import accepts, returns +from controllers.openapi._contract import endpoint from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import ( MemberActionResponse, @@ -35,12 +35,19 @@ from controllers.openapi._models import ( WorkspaceListResponse, WorkspaceSummaryResponse, ) -from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import ( + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, + CheckWorkspaceRole, +) +from controllers.openapi.auth.subjects import AccountSubject from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType +from libs.oauth_bearer import Scope from models import Account, Tenant, TenantAccountJoin -from models.account import TenantAccountRole, TenantStatus +from models.account import TenantAccountRole from services.account_service import AccountService, RegisterService, TenantService from services.errors.account import ( AccountAlreadyInTenantError, @@ -66,20 +73,6 @@ def _member_response(account: Account) -> MemberResponse: ) -def _load_tenant(session: Session, workspace_id: str) -> Tenant: - tenant = TenantService.get_tenant_by_id(workspace_id, session=session) - if tenant is None or tenant.status != TenantStatus.NORMAL: - raise NotFound("workspace not found") - return tenant - - -def _load_account(session: Session, account_id: object) -> Account: - account = AccountService.get_account_by_id(str(account_id), session=session) if account_id else None - if account is None: - raise RuntimeError("authenticated account_id has no Account row") - return account - - def _check_member_invite_quota(tenant_id: str) -> None: features = FeatureService.get_features(tenant_id) @@ -94,23 +87,24 @@ def _check_member_invite_quota(tenant_id: str) -> None: @openapi_ns.route("/workspaces") class WorkspacesApi(Resource): - @auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - @returns(200, WorkspaceListResponse, description="Workspace list") - @with_session(write=False) - def get(self, session: Session, *, auth_data: AuthData): - rows = TenantService.get_workspaces_for_account(str(auth_data.account_id), session=session) + @endpoint( + requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)), + returns=(200, WorkspaceListResponse, "Workspace list"), + ) + def get(self, ctx: Context): + rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session) return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows))) @openapi_ns.route("/workspaces/") class WorkspaceByIdApi(Resource): - @auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - @returns(200, WorkspaceDetailResponse, description="Workspace detail") - @with_session(write=False) - def get(self, session: Session, workspace_id: str, *, auth_data: AuthData): - row = TenantService.find_workspace_for_account(str(auth_data.account_id), workspace_id, session=session) - # 404 (not 403) on non-member so workspace IDs don't leak across tenants. + @endpoint( + requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)), + returns=(200, WorkspaceDetailResponse, "Workspace detail"), + ) + def get(self, ctx: Context, workspace_id: str): + row = TenantService.find_workspace_for_account(str(ctx.subject.account_id), workspace_id, session=ctx.session) if row is None: raise NotFound("workspace not found") @@ -127,18 +121,21 @@ class WorkspaceSwitchApi(Resource): that ``hosts.yml`` never diverges from the server's ``current`` state. """ - @auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - @returns(200, WorkspaceDetailResponse, description="Workspace detail") - @with_session - def post(self, session: Session, workspace_id: str, *, auth_data: AuthData): - account = _load_account(session, auth_data.account_id) - + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_READ), + CheckWorkspaceMember(), + ), + returns=(200, WorkspaceDetailResponse, "Workspace detail"), + ) + def post(self, ctx: Context, workspace_id: str): try: - TenantService.switch_tenant(account, workspace_id, session=session) + TenantService.switch_tenant(ctx.account, workspace_id, session=ctx.session) except AccountNotLinkTenantError: raise NotFound("workspace not found") - row = TenantService.find_workspace_for_account(str(auth_data.account_id), workspace_id, session=session) + row = TenantService.find_workspace_for_account(str(ctx.subject.account_id), workspace_id, session=ctx.session) if row is None: raise NotFound("workspace not found") tenant, membership = row @@ -153,13 +150,17 @@ class WorkspaceMembersApi(Resource): assigned through invite (ownership transfer is console-only). """ - @auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - @returns(200, MemberListResponse, description="Member list") - @accepts(query=MemberListQuery) - @with_session(write=False) - def get(self, session: Session, workspace_id: str, *, auth_data: AuthData, query: MemberListQuery): - tenant = _load_tenant(session, workspace_id) - members = TenantService.get_tenant_members(tenant, session=session) + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_READ), + CheckWorkspaceMember(), + ), + query=MemberListQuery, + returns=(200, MemberListResponse, "Member list"), + ) + def get(self, ctx: Context, workspace_id: str, *, query: MemberListQuery): + members = TenantService.get_tenant_members(ctx.workspace, session=ctx.session) total = len(members) start = (query.page - 1) * query.limit page_items = members[start : start + query.limit] @@ -170,17 +171,19 @@ class WorkspaceMembersApi(Resource): items=[_member_response(m) for m in page_items], ) - @auth_router.guard_workspace( - scope=Scope.WORKSPACE_WRITE, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())), + CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})), + ), + body=MemberInvitePayload, + returns=(201, MemberInviteResponse, "Member invited"), ) - @returns(201, MemberInviteResponse, description="Member invited") - @accepts(body=MemberInvitePayload) - @with_session - def post(self, session: Session, workspace_id: str, *, auth_data: AuthData, body: MemberInvitePayload): - inviter = _load_account(session, auth_data.account_id) - tenant = _load_tenant(session, workspace_id) + def post(self, ctx: Context, workspace_id: str, *, body: MemberInvitePayload): + tenant = ctx.workspace _check_member_invite_quota(str(tenant.id)) @@ -190,8 +193,8 @@ class WorkspaceMembersApi(Resource): email=body.email, language=None, role=body.role, - inviter=inviter, - session=session, + inviter=ctx.account, + session=ctx.session, ) except AccountAlreadyInTenantError as exc: raise BadRequest(str(exc)) @@ -203,9 +206,8 @@ class WorkspaceMembersApi(Resource): raise BadRequest(str(exc)) normalized_email = body.email.lower() - member = AccountService.get_account_by_email_with_case_fallback(normalized_email, session=session) + member = AccountService.get_account_by_email_with_case_fallback(normalized_email, session=ctx.session) if member is None: - # invite_new_member just created or fetched this account. raise RuntimeError("invited member missing from DB after invite") encoded_email = parse.quote(normalized_email) @@ -229,22 +231,23 @@ class WorkspaceMemberApi(Resource): assigned via PATCH (closed enum); admin cannot demote the standing owner. """ - @auth_router.guard_workspace( - scope=Scope.WORKSPACE_WRITE, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())), + CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})), + ), + returns=(200, MemberActionResponse, "Member removed"), ) - @returns(200, MemberActionResponse, description="Member removed") - @with_session - def delete(self, session: Session, workspace_id: str, member_id: str, *, auth_data: AuthData): - operator = _load_account(session, auth_data.account_id) - tenant = _load_tenant(session, workspace_id) - member = AccountService.get_account_by_id(member_id, session=session) + def delete(self, ctx: Context, workspace_id: str, member_id: str): + member = AccountService.get_account_by_id(member_id, session=ctx.session) if member is None: raise NotFound("member not found") try: - TenantService.remove_member_from_tenant(tenant, member, operator, session=session) + TenantService.remove_member_from_tenant(ctx.workspace, member, ctx.account, session=ctx.session) except CannotOperateSelfError as exc: raise BadRequest(str(exc)) except NoPermissionError as exc: @@ -254,31 +257,24 @@ class WorkspaceMemberApi(Resource): return MemberActionResponse() - @auth_router.guard_workspace( - scope=Scope.WORKSPACE_WRITE, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}), + @endpoint( + requirements=( + CheckSubject(allowed=(AccountSubject,)), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())), + CheckWorkspaceRole(frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})), + ), + body=MemberRoleUpdatePayload, + returns=(200, MemberActionResponse, "Role updated"), ) - @returns(200, MemberActionResponse, description="Role updated") - @accepts(body=MemberRoleUpdatePayload) - @with_session - def patch( - self, - session: Session, - workspace_id: str, - member_id: str, - *, - auth_data: AuthData, - body: MemberRoleUpdatePayload, - ): - operator = _load_account(session, auth_data.account_id) - tenant = _load_tenant(session, workspace_id) - member = AccountService.get_account_by_id(member_id, session=session) + def patch(self, ctx: Context, workspace_id: str, member_id: str, *, body: MemberRoleUpdatePayload): + member = AccountService.get_account_by_id(member_id, session=ctx.session) if member is None: raise NotFound("member not found") try: - TenantService.update_member_role(tenant, member, body.role, operator, session=session) + TenantService.update_member_role(ctx.workspace, member, body.role, ctx.account, session=ctx.session) except CannotOperateSelfError as exc: raise BadRequest(str(exc)) except NoPermissionError as exc: diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py index bfd96650b8d..f29064bda51 100644 --- a/api/libs/oauth_bearer.py +++ b/api/libs/oauth_bearer.py @@ -6,7 +6,7 @@ import hashlib import json import logging import uuid -from collections.abc import Callable, Iterable +from collections.abc import Callable, Mapping from contextvars import ContextVar, Token from dataclasses import dataclass from datetime import UTC, datetime @@ -30,16 +30,6 @@ logger = logging.getLogger(__name__) # ============================================================================ -class SubjectType(StrEnum): - ACCOUNT = "account" - EXTERNAL_SSO = "external_sso" - - -class TokenType(StrEnum): - OAUTH_ACCOUNT = "oauth_account" - OAUTH_EXTERNAL_SSO = "oauth_external_sso" - - class Scope(StrEnum): """Catalog of bearer scopes recognised by the openapi surface. @@ -56,24 +46,70 @@ class Scope(StrEnum): WORKSPACE_WRITE = "workspace:write" -@dataclass(frozen=True, slots=True) -class AuthContext: - """Per-request identity published via :data:`_auth_ctx_var` - (see :func:`set_auth_ctx` / :func:`get_auth_ctx`). ``scopes`` / - ``subject_type`` / ``token_type`` come from the TokenKind, not the DB — - corrupt rows can't elevate scope. +class SubjectType(StrEnum): + # Annotation-only names are not members; they declare what `__new__` attaches. + scopes: frozenset[Scope] + # Whether the subject's token rows carry an `account_id`. A subject that does + # not is keyed by its external issuer instead. + bound_to_account: bool + + ACCOUNT = ("account", frozenset({Scope.FULL}), True) + EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False) + + def __new__(cls, value: str, scopes: frozenset[Scope], bound_to_account: bool) -> SubjectType: + obj = str.__new__(cls, value) + obj._value_ = value + obj.scopes = scopes + obj.bound_to_account = bound_to_account + return obj + + +class TokenType(StrEnum): + """Many token types may serve one subject; the prefix is the only thing a + bearer string reveals, so it lives here and nowhere else. """ - subject_type: SubjectType + prefix: str + subject: SubjectType + + OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT) + OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO) + + def __new__(cls, value: str, prefix: str, subject: SubjectType) -> TokenType: + obj = str.__new__(cls, value) + obj._value_ = value + obj.prefix = prefix + obj.subject = subject + return obj + + @classmethod + def for_token(cls, token: str) -> TokenType | None: + return next((token_type for token_type in cls if token.startswith(token_type.prefix)), None) + + +@dataclass(frozen=True, slots=True) +class AuthContext: + """Per-request identity published via :data:`_auth_ctx_var`. Subject and + scopes derive from the token type, never from the row, so a corrupt row + cannot elevate scope. + """ + + token_type: TokenType subject_email: str | None subject_issuer: str | None account_id: uuid.UUID | None client_id: str | None - scopes: frozenset[Scope] token_id: uuid.UUID - token_type: TokenType expires_at: datetime | None + @property + def subject_type(self) -> SubjectType: + return self.token_type.subject + + @property + def scopes(self) -> frozenset[Scope]: + return self.token_type.subject.scopes + _auth_ctx_var: ContextVar[AuthContext] = ContextVar("openapi_auth_ctx") @@ -130,82 +166,10 @@ class Resolver(Protocol): ... -@dataclass(frozen=True, slots=True) -class TokenKind: - prefix: str - subject_type: SubjectType - scopes: frozenset[Scope] - token_type: TokenType - resolver: Resolver - - def matches(self, token: str) -> bool: - return token.startswith(self.prefix) - - -@dataclass(frozen=True, slots=True) -class MintProfile: - """Single source of truth for (subject_type, prefix, scopes) at mint time. - - Consumers: - - ``build_registry`` reads scopes here so the resolve-time TokenKind - cannot drift from the mint-time intent. - - Device-flow ``approve`` / ``approve-external`` read prefix + scopes - here when calling ``mint_oauth_token`` and ``validate_mint_policy``. - - ``services.openapi.mint_policy.validate_mint_policy`` cross-checks - the (subject_type, prefix, scopes) triple a caller intends to mint - against this table — a caller that assembles its own scope set - from a non-canonical source will fail closed at approve time. - """ - - subject_type: SubjectType - prefix: str - scopes: frozenset[Scope] - - -MINTABLE_PROFILES: dict[SubjectType, MintProfile] = { - SubjectType.ACCOUNT: MintProfile( - subject_type=SubjectType.ACCOUNT, - prefix="dfoa_", - scopes=frozenset({Scope.FULL}), - ), - SubjectType.EXTERNAL_SSO: MintProfile( - subject_type=SubjectType.EXTERNAL_SSO, - prefix="dfoe_", - scopes=frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), - ), -} - - class InvalidBearerError(Exception): """Token missing, unknown prefix, or no live row.""" -class TokenExpiredError(Exception): - """Hard-expire bookkeeping is the resolver's job before raising.""" - - -# ============================================================================ -# Registry -# ============================================================================ - - -class TokenKindRegistry: - def __init__(self, kinds: Iterable[TokenKind]) -> None: - self._kinds: tuple[TokenKind, ...] = tuple(kinds) - prefixes = [k.prefix for k in self._kinds] - if len(set(prefixes)) != len(prefixes): - raise ValueError(f"duplicate prefix in registry: {prefixes}") - - def find(self, token: str) -> TokenKind | None: - for k in self._kinds: - if k.matches(token): - return k - return None - - def kinds(self) -> tuple[TokenKind, ...]: - return self._kinds - - # ============================================================================ # Authenticator # ============================================================================ @@ -216,12 +180,8 @@ def sha256_hex(token: str) -> str: class BearerAuthenticator: - def __init__(self, registry: TokenKindRegistry) -> None: - self._registry = registry - - @property - def registry(self) -> TokenKindRegistry: - return self._registry + def __init__(self, resolvers: Mapping[TokenType, Resolver]) -> None: + self._resolvers = resolvers def authenticate(self, token: str) -> AuthContext: """Identity + per-token rate limit (single source). @@ -229,23 +189,21 @@ class BearerAuthenticator: The openapi auth pipeline is the only caller, so the rate limit fires exactly once per request. """ - kind = self._registry.find(token) - if kind is None: + token_type = TokenType.for_token(token) + if token_type is None or token_type not in self._resolvers: raise InvalidBearerError("invalid_bearer") token_hash = sha256_hex(token) enforce_bearer_rate_limit(token_hash) - row = kind.resolver.resolve(token_hash) + row = self._resolvers[token_type].resolve(token_hash) if row is None: raise InvalidBearerError("invalid_bearer") return AuthContext( - subject_type=kind.subject_type, + token_type=token_type, subject_email=row.subject_email, subject_issuer=row.subject_issuer, account_id=row.account_id, client_id=row.client_id, - scopes=kind.scopes, token_id=row.token_id, - token_type=kind.token_type, expires_at=row.expires_at, ) @@ -259,8 +217,6 @@ POSITIVE_TTL_SECONDS = 60 NEGATIVE_TTL_SECONDS = 10 AUDIT_OAUTH_EXPIRED = "oauth.token_expired" -ScopeVariant = Literal["account", "external_sso"] - class _TokenCacheClient(Protocol): def delete(self, *names: str | bytes) -> object: ... @@ -271,9 +227,7 @@ def invalidate_oauth_token_cache(client: _TokenCacheClient, token_hash: str) -> class OAuthAccessTokenResolver: - """``.for_account()`` / ``.for_external_sso()`` are variant-scoped views - sharing DB + cache plumbing. - """ + """``for_token_type()`` returns a view scoped to one token type, sharing DB + cache plumbing.""" def __init__( self, @@ -287,11 +241,8 @@ class OAuthAccessTokenResolver: self._positive_ttl = positive_ttl self._negative_ttl = negative_ttl - def for_account(self) -> Resolver: - return _VariantResolver(self, variant="account") - - def for_external_sso(self) -> Resolver: - return _VariantResolver(self, variant="external_sso") + def for_token_type(self, token_type: TokenType) -> Resolver: + return _TokenTypeResolver(self, token_type) def _cache_key(self, token_hash: str) -> str: return TOKEN_CACHE_KEY_FMT.format(hash=token_hash) @@ -341,17 +292,17 @@ class OAuthAccessTokenResolver: self.cache_set_negative(token_hash) -class _VariantResolver: - def __init__(self, parent: OAuthAccessTokenResolver, variant: ScopeVariant) -> None: +class _TokenTypeResolver: + def __init__(self, parent: OAuthAccessTokenResolver, token_type: TokenType) -> None: self._parent = parent - self._variant = variant + self._token_type = token_type def resolve(self, token_hash: str) -> ResolvedRow | None: cached = self._parent.cache_get(token_hash) if cached == "invalid": return None if cached is not None and not isinstance(cached, str): - if not self._matches_variant(cached): + if not self._matches_subject(cached): return None return cached @@ -368,7 +319,7 @@ class _VariantResolver: self._parent.hard_expire(session, row.id, token_hash) return None - if not self._matches_variant_model(row): + if not (self._matches_subject(row) and row.prefix == self._token_type.prefix): logger.error( "internal_state_invariant: account_id/prefix mismatch token_id=%s prefix=%s", row.id, @@ -387,17 +338,8 @@ class _VariantResolver: self._parent.cache_set_positive(token_hash, resolved) return resolved - def _matches_variant(self, row: ResolvedRow) -> bool: - has_account = row.account_id is not None - if self._variant == "account": - return has_account - return not has_account - - def _matches_variant_model(self, row: OAuthAccessToken) -> bool: - has_account = row.account_id is not None - if self._variant == "account": - return has_account and row.prefix == "dfoa_" - return (not has_account) and row.prefix == "dfoe_" + def _matches_subject(self, row: ResolvedRow | OAuthAccessToken) -> bool: + return (row.account_id is not None) == self._token_type.subject.bound_to_account def _load_from_db(self, session: Session, token_hash: str) -> OAuthAccessToken | None: return ( @@ -443,15 +385,18 @@ def extract_bearer(req) -> str | None: return value.strip() -def bearer_feature_required[**P, R](fn: Callable[P, R]) -> Callable[P, R]: - """503 if ENABLE_OAUTH_BEARER is off — minted tokens would be unusable - without the authenticator, so fail fast instead of approving silently. +def assert_bearer_feature_enabled() -> None: + """503 if ENABLE_OAUTH_BEARER is off: the authenticator is never bound then, + so minted tokens would be unusable and guarded routes would 500. """ + if not dify_config.ENABLE_OAUTH_BEARER: + raise ServiceUnavailable("bearer_auth_disabled: set ENABLE_OAUTH_BEARER=true to enable") + +def bearer_feature_required[**P, R](fn: Callable[P, R]) -> Callable[P, R]: @wraps(fn) def inner(*args: P.args, **kwargs: P.kwargs) -> R: - if not dify_config.ENABLE_OAUTH_BEARER: - raise ServiceUnavailable("bearer_auth_disabled: set ENABLE_OAUTH_BEARER=true to enable") + assert_bearer_feature_enabled() return fn(*args, **kwargs) return inner @@ -462,32 +407,17 @@ def bearer_feature_required[**P, R](fn: Callable[P, R]) -> Callable[P, R]: # ============================================================================ -def build_registry(session_factory, redis_client) -> TokenKindRegistry: +def build_authenticator(session_factory, redis_client) -> BearerAuthenticator: oauth = OAuthAccessTokenResolver(session_factory, redis_client) - account = MINTABLE_PROFILES[SubjectType.ACCOUNT] - external = MINTABLE_PROFILES[SubjectType.EXTERNAL_SSO] - return TokenKindRegistry( - [ - TokenKind( - prefix=account.prefix, - subject_type=account.subject_type, - scopes=account.scopes, - token_type=TokenType.OAUTH_ACCOUNT, - resolver=oauth.for_account(), - ), - TokenKind( - prefix=external.prefix, - subject_type=external.subject_type, - scopes=external.scopes, - token_type=TokenType.OAUTH_EXTERNAL_SSO, - resolver=oauth.for_external_sso(), - ), - ] + return BearerAuthenticator( + { + TokenType.OAUTH_ACCOUNT: oauth.for_token_type(TokenType.OAUTH_ACCOUNT), + TokenType.OAUTH_EXTERNAL_SSO: oauth.for_token_type(TokenType.OAUTH_EXTERNAL_SSO), + } ) def build_and_bind(session_factory, redis_client) -> BearerAuthenticator: - registry = build_registry(session_factory, redis_client) - auth = BearerAuthenticator(registry) + auth = build_authenticator(session_factory, redis_client) bind_authenticator(auth) return auth diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index a793cbcd2b9..cbb8b6faa84 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -173,6 +173,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Form definition | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [POST] /apps/{app_id}/human-input-forms/{form_token}:submit #### Parameters @@ -211,6 +212,7 @@ Upload a file to use as an input variable when running the app | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [POST] /apps/{app_id}/tasks/{task_id}:stop #### Parameters @@ -246,6 +248,7 @@ Upload a file to use as an input variable when running the app | ---- | ----------- | ------ | | 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)
| | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| ### [POST] /oauth/device/approve #### Request Body @@ -515,7 +518,7 @@ Upload a file to use as an input variable when running the app | default_workspace_id | string | | No | | subject_email | string | | No | | subject_issuer | string | | No | -| subject_type | string | | Yes | +| subject_type | [SubjectType](#subjecttype) | | Yes | | workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | #### AppDescribeInfo @@ -715,7 +718,7 @@ Enum representing the deployment edition of the platform. | expires_at | string | | Yes | | subject_email | string | | No | | subject_issuer | string | | No | -| subject_type | string,
**Available values:** "account", "external_sso" | *Enum:* `"account"`, `"external_sso"` | Yes | +| subject_type | [SubjectType](#subjecttype) | | Yes | | token | string | | Yes | | token_id | string | | Yes | | workspaces | [ [WorkspacePayload](#workspacepayload) ],
**Default:** | | No | @@ -1022,6 +1025,12 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | ---- | ---- | ----------- | -------- | | result | string | Operation result. | Yes | +#### SubjectType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SubjectType | string | | | + #### SupportedAppType App types the ``app`` usage face (``get app``) lists and filters. diff --git a/api/services/account_service.py b/api/services/account_service.py index 81ee3aeb037..c907b421fb7 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -1231,8 +1231,8 @@ class TenantService: @staticmethod def account_belongs_to_tenant(account_id: uuid.UUID | str | None, tenant_id: str, *, session: Session) -> bool: """Existence check for ``TenantAccountJoin(account_id, tenant_id)``. - Backs the CE-deployment membership fallback in - ``controllers.openapi.auth.strategies.MembershipStrategy``. + Membership without the role: where the openapi auth layer needs the role + itself it reads ``Context.workspace_role`` instead. ``None``/empty ``account_id`` short-circuits to ``False`` so SSO bearers (no account) and missing identity collapse cleanly. @@ -1253,9 +1253,9 @@ class TenantService: ) -> TenantAccountRole | None: """Return the caller's role in ``tenant_id``, or ``None`` if not a member. - Backs the openapi auth pipeline's ``load_workspace_role`` prepare step: - ``None`` is treated as non-member (the pipeline maps it to 404 — no - cross-tenant ID leak) and an out-of-set role to 403. + Backs the openapi auth layer's ``Context.workspace_role``: ``None`` is + treated as non-member (mapped to 404 — no cross-tenant ID leak) and an + out-of-set role to 403. ``None``/empty ``account_id`` short-circuits to ``None`` so SSO bearers (no account) collapse to the non-member path. Mirrors the diff --git a/api/services/app_service.py b/api/services/app_service.py index 859f87c09a7..f829bcc3f24 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -365,25 +365,6 @@ class AppService: return [] return list(session.execute(apply_openapi_gate(select(App).where(App.id.in_(list(app_ids))))).scalars().all()) - @staticmethod - def find_visible_apps_by_name( - session: Session, - *, - name: str, - tenant_id: str, - ) -> list[App]: - return list( - session.execute( - apply_openapi_gate( - select(App).where( - App.name == name, - App.tenant_id == tenant_id, - App.status == "normal", - ) - ) - ).scalars() - ) - def get_paginate_apps( self, user_id: str, diff --git a/api/services/oauth_device_flow.py b/api/services/oauth_device_flow.py index f3967026aad..2704fade27d 100644 --- a/api/services/oauth_device_flow.py +++ b/api/services/oauth_device_flow.py @@ -15,7 +15,7 @@ from typing import NotRequired, TypedDict from sqlalchemy import func, select, update from sqlalchemy.orm import Session -from libs.oauth_bearer import SubjectType, invalidate_oauth_token_cache +from libs.oauth_bearer import SubjectType, TokenType, invalidate_oauth_token_cache from models.oauth import OAuthAccessToken logger = logging.getLogger(__name__) @@ -300,8 +300,6 @@ class DeviceFlowRedis: OAUTH_BODY_BYTES = 32 # ~256 bits entropy -PREFIX_OAUTH_ACCOUNT = "dfoa_" -PREFIX_OAUTH_EXTERNAL_SSO = "dfoe_" # Sentinel issuer for account-flow rows. Postgres' default partial unique # index treats NULLs as distinct, which would let two live `dfoa_` rows @@ -342,7 +340,7 @@ def mint_oauth_token( account_id: str | None, client_id: str, device_label: str, - prefix: str, + token_type: TokenType, ttl_days: int, session: Session, ) -> MintResult: @@ -351,21 +349,18 @@ def mint_oauth_token( index predicate so re-login INSERTs fresh. Pre-rotate Redis entry is deleted so stale AuthContext drops immediately. """ - if prefix == PREFIX_OAUTH_ACCOUNT: - # Account flow always writes the sentinel — caller may pass None + if token_type.subject.bound_to_account: + # Account-bound flows always write the sentinel — caller may pass None # (for clarity) or the sentinel itself; nothing else is valid. if subject_issuer not in (None, ACCOUNT_ISSUER_SENTINEL): raise ValueError(f"account-flow token must use ACCOUNT_ISSUER_SENTINEL, got {subject_issuer!r}") subject_issuer = ACCOUNT_ISSUER_SENTINEL - elif prefix == PREFIX_OAUTH_EXTERNAL_SSO: + elif not subject_issuer or not subject_issuer.strip(): # Defense in depth: enterprise canonicalises + rejects empty, # but a regression there must not yield a NULL composite key here. - if not subject_issuer or not subject_issuer.strip(): - raise ValueError("external-SSO token requires non-empty subject_issuer") - else: - raise ValueError(f"unknown oauth prefix: {prefix!r}") + raise ValueError("external-SSO token requires non-empty subject_issuer") - token = generate_token(prefix) + token = generate_token(token_type.prefix) new_hash = sha256_hex(token) expires_at = datetime.now(UTC) + timedelta(days=ttl_days) @@ -376,7 +371,7 @@ def mint_oauth_token( account_id=account_id, client_id=client_id, device_label=device_label, - prefix=prefix, + prefix=token_type.prefix, new_hash=new_hash, expires_at=expires_at, ) diff --git a/api/services/openapi/mint_policy.py b/api/services/openapi/mint_policy.py deleted file mode 100644 index 303ecc3209c..00000000000 --- a/api/services/openapi/mint_policy.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Hard mint policy. - -``validate_mint_policy`` cross-checks a (subject_type, prefix, scopes) -triple a caller intends to mint against ``MINTABLE_PROFILES`` — -the single source of truth in ``libs.oauth_bearer``. - -The defense-in-depth value: if a future caller assembles ``prefix`` or -``scopes`` from a non-canonical source (env, request body, plug-in -contribution), the mismatch fails closed at approve time before any -row hits the DB. When the caller reads straight from -``MINTABLE_PROFILES``, the check is a structural pin — it confirms the -table entry is well-formed and the caller picked the right key. -""" - -from __future__ import annotations - -from libs.oauth_bearer import MINTABLE_PROFILES, Scope, SubjectType - - -class MintPolicyViolation(Exception): # noqa: N818 — spec-defined name, used in BadRequest message - """Raised on a (subject_type, prefix, scopes) mismatch. Callers translate - to 400 ``mint_policy_violation``.""" - - -def validate_mint_policy( - *, - subject_type: SubjectType, - prefix: str, - scopes: frozenset[Scope], -) -> None: - """Raise ``MintPolicyViolation`` when the triple does not match the - canonical ``MINTABLE_PROFILES`` entry for ``subject_type``. - """ - profile = MINTABLE_PROFILES.get(subject_type) - if profile is None: - raise MintPolicyViolation(f"mint_policy_violation: unknown subject_type={subject_type!r}") - - drift = [] - if profile.prefix != prefix: - drift.append(f"prefix got={prefix!r} expected={profile.prefix!r}") - if frozenset(scopes) != profile.scopes: - got = sorted(s.value for s in scopes) - want = sorted(s.value for s in profile.scopes) - drift.append(f"scopes got={got} expected={want}") - - if drift: - raise MintPolicyViolation(f"mint_policy_violation: subject_type={subject_type.value} — " + "; ".join(drift)) diff --git a/api/tests/integration_tests/controllers/openapi/conftest.py b/api/tests/integration_tests/controllers/openapi/conftest.py index d9357e81551..7b7e4f5bdac 100644 --- a/api/tests/integration_tests/controllers/openapi/conftest.py +++ b/api/tests/integration_tests/controllers/openapi/conftest.py @@ -13,6 +13,7 @@ from flask import Flask from enums import DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client +from libs.oauth_bearer import TokenType from models import Account, App, OAuthAccessToken, Tenant, TenantAccountJoin from models.account import AccountStatus @@ -101,11 +102,11 @@ def mint_token(flask_app: Flask): @pytest.fixture def account_token(workspace_account, mint_token) -> str: account, _, _ = workspace_account - token = "dfoa_" + uuid.uuid4().hex + token = TokenType.OAUTH_ACCOUNT.prefix + uuid.uuid4().hex mint_token( token, account_id=account.id, - prefix="dfoa_", + prefix=TokenType.OAUTH_ACCOUNT.prefix, subject_email=account.email, subject_issuer="dify:account", ) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py index 1b7894bcb8a..91612174a21 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations import uuid from collections.abc import Callable -from typing import Literal from unittest.mock import patch import pytest @@ -10,13 +9,17 @@ from faker import Faker from flask import Flask from sqlalchemy.orm import Session -from controllers.openapi.auth.data import AuthData -from libs.oauth_bearer import Scope, TokenType -from machinery.context import AccountRequestContext +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.loaders import PathParam, load_app +from controllers.openapi.auth.requirements import ResolveCaller +from controllers.openapi.auth.subjects import subject_from_auth +from libs.oauth_bearer import AuthContext, TokenType from models import Account, Tenant from services.account_service import AccountService, TenantService from tests.test_containers_integration_tests.helpers import generate_valid_password +_CLIENT_ID = "integration-cli" + @pytest.fixture def app(flask_app_with_containers: Flask) -> Flask: @@ -67,37 +70,51 @@ def add_tenant_for_account( return tenant -def auth_for( +def _account_auth( account: Account, *, - app_model: object | None = None, token_id: uuid.UUID | None = None, - caller_kind: Literal["account", "end_user"] | None = None, -) -> AuthData: - """Build an AuthData for ``account`` (and optionally an app context). - - ``token_id`` is needed by the self-revoke endpoint, and ``caller_kind`` by - any handler calling ``require_app_context`` (e.g. file upload / task stop). - """ - return AuthData( - token_type=TokenType.OAUTH_ACCOUNT, + client_id: str = _CLIENT_ID, +) -> AuthContext: + """The ``AuthContext`` a live ``dfoa_`` token for ``account`` would carry.""" + return AuthContext( + subject_email=account.email, + subject_issuer=None, account_id=uuid.UUID(str(account.id)), - token_id=token_id, - scopes=frozenset({Scope.FULL}), - caller=account, - caller_kind=caller_kind, - app=app_model, # type: ignore[arg-type] + client_id=client_id, + token_id=token_id or uuid.uuid4(), + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, ) -def request_context_for( +def context_for( account: Account, *, + session: Session, + view_args: dict[str, str] | None = None, token_id: uuid.UUID | None = None, -) -> AccountRequestContext: - return AccountRequestContext( - request_id="integration-request", - trace_id="integration-trace", - account_id=str(account.id), - access_token_id=str(token_id) if token_id is not None else None, - ) +) -> Context: + """Build the ``Context`` a handler is given after the pipeline ran. + + The subject comes from a real ``AuthContext`` through ``subject_from_auth``, + so the helper walks the same resolution path the router does. ``view_args`` + is the route's path params — the app and the workspace are loaded from it, + so a route carrying ```` needs ``{"app_id": ...}`` here. + ``token_id`` only matters to the ``/account/sessions*`` family, which reads + it back off the subject. + + It runs ``ResolveCaller`` itself — the requirement every pipeline fixes + last — rather than the wider set a route's own requirements would ask for, + so a handler sees exactly what the thinnest pipeline would give it. On a + route carrying ```` that thinnest pipeline is ``CheckAppApiEnabled``, + which loads the app; handlers read ``ctx.app`` and never load it themselves, + so the helper loads it through the same loader. Running the real pieces is + what keeps this CI-only helper from drifting away from the pipeline it + stands in for. + """ + ctx = Context(subject_from_auth(_account_auth(account, token_id=token_id)), session, view_args or {}) + if PathParam.APP_ID in ctx.view_args: + load_app(ctx) + ResolveCaller().run(ctx.subject, ctx, session) + return ctx diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py index d2181b8398d..3211b6d9cd3 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_account.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -from inspect import unwrap from flask import Flask from sqlalchemy.orm import Session @@ -9,21 +8,20 @@ from sqlalchemy.orm import Session from controllers.openapi.account import AccountApi from models import Account from models.account import TenantAccountRole -from tests.test_containers_integration_tests.controllers.openapi.conftest import ( - add_tenant_for_account, - request_context_for, -) +from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, context_for class TestAccountInfo: - def test_returns_account_and_owner_workspace(self, app: Flask, make_account: Callable[..., Account]) -> None: + def test_returns_account_and_owner_workspace( + self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] + ) -> None: account = make_account() owner_tenant = account.current_tenant assert owner_tenant is not None api = AccountApi() with app.test_request_context("/openapi/v1/account"): - result = unwrap(api.get)(api, request_context_for(account)) + result = api.get.__handler__(api, context_for(account, session=db_session_with_containers)) assert result.subject_type == "account" assert result.subject_email == account.email @@ -48,7 +46,7 @@ class TestAccountInfo: api = AccountApi() with app.test_request_context("/openapi/v1/account"): - result = unwrap(api.get)(api, request_context_for(account)) + result = api.get.__handler__(api, context_for(account, session=db_session_with_containers)) assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} roles = {w.id: w.role for w in result.workspaces} diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py index d2e339c565e..bed322888a3 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_account_sessions.py @@ -1,8 +1,6 @@ from __future__ import annotations from collections.abc import Callable -from inspect import unwrap -from uuid import uuid4 import pytest from flask import Flask @@ -16,9 +14,10 @@ from controllers.openapi.account import ( AccountSessionsSelfApi, ) from extensions.ext_redis import redis_client +from libs.oauth_bearer import TokenType from models import Account -from services.oauth_device_flow import PREFIX_OAUTH_ACCOUNT, MintResult, mint_oauth_token -from tests.test_containers_integration_tests.controllers.openapi.conftest import request_context_for +from services.oauth_device_flow import MintResult, mint_oauth_token +from tests.test_containers_integration_tests.controllers.openapi.conftest import context_for def _mint_account_token( @@ -36,7 +35,7 @@ def _mint_account_token( account_id=str(account.id), client_id=client_id, device_label=device_label, - prefix=PREFIX_OAUTH_ACCOUNT, + token_type=TokenType.OAUTH_ACCOUNT, ttl_days=14, session=db_session, ) @@ -51,16 +50,16 @@ class TestSessionList: api = AccountSessionsApi() with app.test_request_context("/openapi/v1/account/sessions"): - result = unwrap(api.get)( + result = api.get.__handler__( api, - request_context_for(account, token_id=mint.token_id), + context_for(account, session=db_session_with_containers, token_id=mint.token_id), query=SessionListQuery(), ) assert result.total == 1 row = result.data[0] assert row.id == str(mint.token_id) - assert row.prefix == PREFIX_OAUTH_ACCOUNT + assert row.prefix == TokenType.OAUTH_ACCOUNT.prefix assert row.device_label == "Laptop" def test_excludes_other_accounts_sessions( @@ -74,9 +73,9 @@ class TestSessionList: api = AccountSessionsApi() with app.test_request_context("/openapi/v1/account/sessions"): - result = unwrap(api.get)( + result = api.get.__handler__( api, - request_context_for(account, token_id=mine.token_id), + context_for(account, session=db_session_with_containers, token_id=mine.token_id), query=SessionListQuery(), ) @@ -92,16 +91,18 @@ class TestSessionRevoke: revoke_api = AccountSessionsSelfApi() with app.test_request_context("/openapi/v1/account/sessions/self", method="DELETE"): - result = unwrap(revoke_api.delete)(revoke_api, request_context_for(account, token_id=mint.token_id)) + result = revoke_api.delete.__handler__( + revoke_api, context_for(account, session=db_session_with_containers, token_id=mint.token_id) + ) assert result.status == "revoked" # Revocation persisted: the real list path no longer returns it. list_api = AccountSessionsApi() with app.test_request_context("/openapi/v1/account/sessions"): - listing = unwrap(list_api.get)( + listing = list_api.get.__handler__( list_api, - request_context_for(account, token_id=mint.token_id), + context_for(account, session=db_session_with_containers, token_id=mint.token_id), query=SessionListQuery(), ) assert listing.total == 0 @@ -114,12 +115,14 @@ class TestSessionRevoke: session_id = str(mint.token_id) api = AccountSessionByIdApi() + ctx = context_for( + account, + session=db_session_with_containers, + view_args={"session_id": session_id}, + token_id=mint.token_id, + ) with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): - result = unwrap(api.delete)( - api, - request_context_for(account, token_id=mint.token_id), - session_id=session_id, - ) + result = api.delete.__handler__(api, ctx, session_id) assert result.status == "revoked" @@ -127,17 +130,15 @@ class TestSessionRevoke: self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] ) -> None: """A token id owned by another subject must be indistinguishable from a - missing one (404), so token ids can't be probed across subjects.""" + missing one (404), so token ids can't be probed across subjects. The + access service makes that call; the handler only maps it to 404.""" owner = make_account() outsider = make_account() foreign = _mint_account_token(db_session_with_containers, owner) - api = AccountSessionByIdApi() session_id = str(foreign.token_id) + ctx = context_for(outsider, session=db_session_with_containers, view_args={"session_id": session_id}) with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): + api = AccountSessionByIdApi() with pytest.raises(NotFound): - unwrap(api.delete)( - api, - request_context_for(outsider, token_id=uuid4()), - session_id=session_id, - ) + api.delete.__handler__(api, ctx, session_id) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py index 3e4ca88288c..39e9ef61be2 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_dsl.py @@ -2,7 +2,6 @@ from __future__ import annotations import json from collections.abc import Callable, Generator -from inspect import unwrap from unittest.mock import patch from uuid import uuid4 @@ -25,7 +24,7 @@ from services.account_service import AccountService, TenantService from services.app_dsl_service import CURRENT_DSL_VERSION from services.app_service import AppService, CreateAppParams from services.entities.dsl_entities import ImportStatus -from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for +from tests.test_containers_integration_tests.controllers.openapi.conftest import context_for from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -111,7 +110,12 @@ class TestDslImport: api = AppDslImportApi() body = AppDslImportPayload(mode="yaml-content", yaml_content="[]") # not a mapping with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): - result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + result, code = api.post.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"workspace_id": tenant.id}), + tenant.id, + body=body, + ) assert code == 400 assert result.status == ImportStatus.FAILED @@ -128,7 +132,12 @@ class TestDslImport: api = AppDslImportApi() body = AppDslImportPayload(mode="yaml-content", yaml_content=_workflow_yaml(version="99.0.0")) with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): - result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + result, code = api.post.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"workspace_id": tenant.id}), + tenant.id, + body=body, + ) assert code == 202 assert result.status == ImportStatus.PENDING @@ -148,7 +157,12 @@ class TestDslImport: api = AppDslImportApi() body = AppDslImportPayload(mode="yaml-content", yaml_content=_workflow_yaml(name="Imported")) with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}/apps/imports", method="POST"): - result, code = unwrap(api.post)(api, workspace_id=tenant.id, auth_data=auth_for(account), body=body) + result, code = api.post.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"workspace_id": tenant.id}), + tenant.id, + body=body, + ) assert code == 200 assert result.status in (ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS) @@ -169,8 +183,15 @@ class TestDslImportConfirm: with app.test_request_context( f"/openapi/v1/workspaces/{tenant.id}/apps/imports/{import_id}:confirm", method="POST" ): - result, code = unwrap(api.post)( - api, workspace_id=tenant.id, import_id=import_id, auth_data=auth_for(account) + result, code = api.post.__handler__( + api, + context_for( + account, + session=db_session_with_containers, + view_args={"workspace_id": tenant.id, "import_id": import_id}, + ), + tenant.id, + import_id, ) assert code == 400 @@ -199,8 +220,11 @@ class TestDslExport: api = AppDslExportApi() with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"): - response, code = unwrap(api.get)( - api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() + response, code = api.get.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"app_id": app_model.id}), + app_model.id, + query=AppDslExportQuery(), ) assert code == 200 @@ -217,8 +241,11 @@ class TestDslExport: api = AppDslExportApi() with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dsl"): - result, code = unwrap(api.get)( - api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model), query=AppDslExportQuery() + result, code = api.get.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"app_id": app_model.id}), + app_model.id, + query=AppDslExportQuery(), ) assert code == 404 @@ -233,7 +260,11 @@ class TestDslCheckDependencies: api = AppDslCheckDependenciesApi() with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/dependencies:check"): - result, code = unwrap(api.get)(api, app_id=app_model.id, auth_data=auth_for(account, app_model=app_model)) + result, code = api.get.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"app_id": app_model.id}), + app_model.id, + ) assert code == 200 assert result.leaked_dependencies == [] diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py index df4f3873b1e..92b7f91f7b2 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_app_run.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -from inspect import unwrap from uuid import uuid4 from flask import Flask @@ -10,7 +9,7 @@ from sqlalchemy.orm import Session from controllers.openapi.app_run import AppRunTaskStopApi from models import Account, App from services.app_service import AppService, CreateAppParams -from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for +from tests.test_containers_integration_tests.controllers.openapi.conftest import context_for def _create_app(db_session: Session, account: Account, *, name: str = "Runner") -> App: @@ -39,11 +38,15 @@ class TestAppRunTaskStop: api = AppRunTaskStopApi() with app.test_request_context(f"/openapi/v1/apps/{app_model.id}/tasks/{task_id}:stop", method="POST"): - result = unwrap(api.post)( + result = api.post.__handler__( api, - app_id=app_model.id, - task_id=task_id, - auth_data=auth_for(account, app_model=app_model, caller_kind="account"), + context_for( + account, + session=db_session_with_containers, + view_args={"app_id": app_model.id, "task_id": task_id}, + ), + app_model.id, + task_id, ) assert result.result == "success" diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py index 52f70b9d590..959c9fadb0c 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_apps.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -from inspect import unwrap from uuid import uuid4 import pytest @@ -13,7 +12,7 @@ from controllers.openapi._models import AppDescribeQuery, AppListQuery from controllers.openapi.apps import AppDescribeApi, AppListApi from models import Account, App from services.app_service import AppService, CreateAppParams -from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for +from tests.test_containers_integration_tests.controllers.openapi.conftest import context_for def _create_app( @@ -61,10 +60,9 @@ class TestAppList: api = AppListApi() with app.test_request_context(f"/openapi/v1/apps?workspace_id={tenant.id}"): - result = unwrap(api.get)( + result = api.get.__handler__( api, - db_session_with_containers, - auth_data=auth_for(account), + context_for(account, session=db_session_with_containers), query=AppListQuery(workspace_id=str(tenant.id)), ) @@ -84,10 +82,9 @@ class TestAppList: api = AppListApi() with app.test_request_context(f"/openapi/v1/apps?workspace_id={tenant.id}&name={target.id}"): - result = unwrap(api.get)( + result = api.get.__handler__( api, - db_session_with_containers, - auth_data=auth_for(account), + context_for(account, session=db_session_with_containers), query=AppListQuery(workspace_id=str(tenant.id), name=str(target.id)), ) @@ -108,10 +105,9 @@ class TestAppList: api = AppListApi() with app.test_request_context(f"/openapi/v1/apps?workspace_id={outsider_tenant.id}&name={foreign_app.id}"): - result = unwrap(api.get)( + result = api.get.__handler__( api, - db_session_with_containers, - auth_data=auth_for(outsider), + context_for(outsider, session=db_session_with_containers), query=AppListQuery(workspace_id=str(outsider_tenant.id), name=str(foreign_app.id)), ) @@ -128,11 +124,10 @@ class TestAppDescribe: api = AppDescribeApi() with app.test_request_context(f"/openapi/v1/apps/{app_model.id}?fields=info"): - result = unwrap(api.get)( + result = api.get.__handler__( api, - db_session_with_containers, - app_id=app_model.id, - auth_data=auth_for(account), + context_for(account, session=db_session_with_containers, view_args={"app_id": app_model.id}), + app_model.id, query=AppDescribeQuery(fields="info"), ) @@ -153,29 +148,9 @@ class TestAppDescribe: api = AppDescribeApi() with app.test_request_context(f"/openapi/v1/apps/{missing_id}"): with pytest.raises(NotFound): - unwrap(api.get)( + api.get.__handler__( api, - db_session_with_containers, - app_id=missing_id, - auth_data=auth_for(account), - query=AppDescribeQuery(), - ) - - def test_describe_api_disabled_app_is_404( - self, app: Flask, db_session_with_containers: Session, make_account: Callable[..., Account] - ) -> None: - """An api-disabled app fails the openapi visibility gate, so describe - must behave as if it doesn't exist (404), not expose it.""" - account = make_account() - hidden = _create_app(db_session_with_containers, account, name="Hidden", enable_api=False) - - api = AppDescribeApi() - with app.test_request_context(f"/openapi/v1/apps/{hidden.id}"): - with pytest.raises(NotFound): - unwrap(api.get)( - api, - db_session_with_containers, - app_id=hidden.id, - auth_data=auth_for(account), + context_for(account, session=db_session_with_containers, view_args={"app_id": missing_id}), + missing_id, query=AppDescribeQuery(), ) diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py index 31a5485d2b3..45079c1ee7e 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_files.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -from inspect import unwrap from io import BytesIO from flask import Flask @@ -10,7 +9,7 @@ from sqlalchemy.orm import Session from controllers.openapi.files import AppFileUploadApi from models import Account, App from services.app_service import AppService, CreateAppParams -from tests.test_containers_integration_tests.controllers.openapi.conftest import auth_for +from tests.test_containers_integration_tests.controllers.openapi.conftest import context_for def _create_app(db_session: Session, account: Account, *, name: str = "Uploader") -> App: @@ -48,10 +47,10 @@ class TestAppFileUpload: data=data, content_type="multipart/form-data", ): - result = unwrap(api.post)( + result = api.post.__handler__( api, - app_id=app_model.id, - auth_data=auth_for(account, app_model=app_model, caller_kind="account"), + context_for(account, session=db_session_with_containers, view_args={"app_id": app_model.id}), + app_model.id, ) assert result.id diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py index 1346e013e66..ebe58bef716 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/test_workspaces.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Callable -from inspect import unwrap import pytest from flask import Flask @@ -11,7 +10,7 @@ from werkzeug.exceptions import NotFound from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi, WorkspaceSwitchApi from models import Account from models.account import TenantAccountRole -from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, auth_for +from tests.test_containers_integration_tests.controllers.openapi.conftest import add_tenant_for_account, context_for class TestWorkspacesList: @@ -24,7 +23,7 @@ class TestWorkspacesList: api = WorkspacesApi() with app.test_request_context("/openapi/v1/workspaces"): - result = unwrap(api.get)(api, db_session_with_containers, auth_data=auth_for(account)) + result = api.get.__handler__(api, context_for(account, session=db_session_with_containers)) ids = {w.id for w in result.workspaces} assert ids == {owner_tenant.id} @@ -45,7 +44,7 @@ class TestWorkspacesList: api = WorkspacesApi() with app.test_request_context("/openapi/v1/workspaces"): - result = unwrap(api.get)(api, db_session_with_containers, auth_data=auth_for(account)) + result = api.get.__handler__(api, context_for(account, session=db_session_with_containers)) assert {w.id for w in result.workspaces} == {owner_tenant.id, second.id} @@ -60,8 +59,10 @@ class TestWorkspaceDetail: api = WorkspaceByIdApi() with app.test_request_context(f"/openapi/v1/workspaces/{tenant.id}"): - detail = unwrap(api.get)( - api, db_session_with_containers, workspace_id=tenant.id, auth_data=auth_for(account) + detail = api.get.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"workspace_id": tenant.id}), + tenant.id, ) assert detail.id == tenant.id @@ -82,8 +83,14 @@ class TestWorkspaceDetail: api = WorkspaceByIdApi() with app.test_request_context(f"/openapi/v1/workspaces/{someone_elses_ws.id}"): with pytest.raises(NotFound): - unwrap(api.get)( - api, db_session_with_containers, workspace_id=someone_elses_ws.id, auth_data=auth_for(outsider) + api.get.__handler__( + api, + context_for( + outsider, + session=db_session_with_containers, + view_args={"workspace_id": someone_elses_ws.id}, + ), + someone_elses_ws.id, ) @@ -100,8 +107,10 @@ class TestWorkspaceSwitch: api = WorkspaceSwitchApi() with app.test_request_context(f"/openapi/v1/workspaces/{target.id}/switch", method="POST"): - detail = unwrap(api.post)( - api, db_session_with_containers, workspace_id=target.id, auth_data=auth_for(account) + detail = api.post.__handler__( + api, + context_for(account, session=db_session_with_containers, view_args={"workspace_id": target.id}), + target.id, ) # Response reflects the post-switch state. @@ -110,10 +119,9 @@ class TestWorkspaceSwitch: # And the switch persisted: the previously-current owner workspace is no # longer current (verified through the real read path). + listing_api = WorkspacesApi() with app.test_request_context("/openapi/v1/workspaces"): - listing = unwrap(WorkspacesApi().get)( - WorkspacesApi(), db_session_with_containers, auth_data=auth_for(account) - ) + listing = listing_api.get.__handler__(listing_api, context_for(account, session=db_session_with_containers)) by_id = {w.id: w for w in listing.workspaces} assert by_id[target.id].current is True assert by_id[owner_tenant.id].current is False @@ -128,6 +136,10 @@ class TestWorkspaceSwitch: api = WorkspaceSwitchApi() with app.test_request_context(f"/openapi/v1/workspaces/{outsider_ws.id}:switch", method="POST"): with pytest.raises(NotFound): - unwrap(api.post)( - api, db_session_with_containers, workspace_id=outsider_ws.id, auth_data=auth_for(account) + api.post.__handler__( + api, + context_for( + account, session=db_session_with_containers, view_args={"workspace_id": outsider_ws.id} + ), + outsider_ws.id, ) diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml index bf1701447c7..7c0feb1323f 100644 --- a/api/tests/test_containers_integration_tests/pyrefly.toml +++ b/api/tests/test_containers_integration_tests/pyrefly.toml @@ -15,6 +15,13 @@ project-excludes = [ "controllers/console/test_api_based_extension.py", "controllers/console/test_apikey.py", "controllers/console/workspace/test_workspace_wraps.py", + "controllers/openapi/test_account.py", + "controllers/openapi/test_account_sessions.py", + "controllers/openapi/test_app_dsl.py", + "controllers/openapi/test_app_run.py", + "controllers/openapi/test_apps.py", + "controllers/openapi/test_files.py", + "controllers/openapi/test_workspaces.py", "controllers/service_api/dataset/test_dataset.py", "controllers/web/test_conversation.py", "controllers/web/test_site.py", diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml index 09056d4e952..b7fcaba4b7a 100644 --- a/api/tests/unit_tests/.ruff.toml +++ b/api/tests/unit_tests/.ruff.toml @@ -66,9 +66,6 @@ extend-select = ["ANN401", "ARG"] "controllers/inner_api/plugin/test_plugin_wraps.py" = ["ARG001", "ARG002", "ARG003", "TID251"] "controllers/inner_api/test_runtime_credentials.py" = ["ARG001"] "controllers/mcp/test_mcp.py" = ["ARG002"] -"controllers/openapi/auth/test_conditions.py" = ["ARG005"] -"controllers/openapi/auth/test_flow.py" = ["ARG005"] -"controllers/openapi/auth/test_pipeline.py" = ["ARG001"] "controllers/openapi/conftest.py" = ["ARG001"] "controllers/openapi/test_account.py" = ["ARG005"] "controllers/openapi/test_app_describe_builder.py" = ["ARG001"] diff --git a/api/tests/unit_tests/controllers/openapi/auth/_world.py b/api/tests/unit_tests/controllers/openapi/auth/_world.py new file mode 100644 index 00000000000..1337cea7872 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/_world.py @@ -0,0 +1,127 @@ +"""The rows, tokens and subjects the auth-layer tests build their worlds from. + +One copy: every module under `auth/` was carrying its own identical `App`, +`Tenant`, `Account` and `AuthContext` builders, so a column added to any of +those models had to be chased through six files. +""" + +from __future__ import annotations + +import uuid +from typing import NoReturn + +from sqlalchemy.orm import Session + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject +from enums import DeploymentEdition +from libs.oauth_bearer import AuthContext, SubjectType, TokenType +from models import Account, App, Tenant, TenantAccountJoin +from models.account import AccountStatus, TenantAccountRole, TenantStatus +from models.enums import AppStatus +from models.model import AppMode, IconType +from services.enterprise.enterprise_service import WebAppSettings +from services.entities.feature_entities import ( + LicenseStatus, + LicenseStatusModel, + SystemFeatureModel, + WebAppAuthModel, +) + +APP_ID = "00000000-0000-0000-0000-000000000001" +TENANT_ID = "00000000-0000-0000-0000-000000000002" +ACCOUNT_ID = "00000000-0000-0000-0000-000000000003" +TOKEN_ID = "00000000-0000-0000-0000-000000000004" +SESSION_ID = "00000000-0000-0000-0000-000000000005" +OTHER_TENANT_ID = "00000000-0000-0000-0000-000000000006" +CLIENT_ID = "openapi-client" +ACCOUNT_EMAIL = "account@example.com" +SSO_EMAIL = "user@sso.com" +SSO_ISSUER = "https://idp.example" + + +def make_auth(token_type: TokenType, **overrides: object) -> AuthContext: + """A live token of either kind. `overrides` names the one field a test varies.""" + is_account = token_type.subject is SubjectType.ACCOUNT + fields: dict[str, object] = { + "token_type": token_type, + "subject_email": None if is_account else SSO_EMAIL, + "subject_issuer": None if is_account else SSO_ISSUER, + "account_id": uuid.UUID(ACCOUNT_ID) if is_account else None, + "client_id": CLIENT_ID, + "token_id": uuid.UUID(TOKEN_ID), + "expires_at": None, + } + fields.update(overrides) + return AuthContext(**fields) # type: ignore[arg-type] + + +def account_subject(**overrides: object) -> AccountSubject: + return AccountSubject(make_auth(TokenType.OAUTH_ACCOUNT, **overrides)) + + +def sso_subject(**overrides: object) -> ExternalSsoSubject: + return ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO, **overrides)) + + +def make_app(*, app_id: str = APP_ID, tenant_id: str = TENANT_ID, enable_api: bool = True) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="OpenAPI app", + description="", + mode=AppMode.CHAT, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#FFFFFF", + status=AppStatus.NORMAL, + enable_site=True, + enable_api=enable_api, + max_active_requests=None, + ) + + +def make_tenant(*, tenant_id: str = TENANT_ID, status: TenantStatus = TenantStatus.NORMAL) -> Tenant: + tenant = Tenant(name="OpenAPI tenant", status=status) + tenant.id = tenant_id + return tenant + + +def make_account(*, status: AccountStatus = AccountStatus.ACTIVE, email: str = ACCOUNT_EMAIL) -> Account: + account = Account(name="OpenAPI account", email=email, status=status) + account.id = ACCOUNT_ID + return account + + +def make_membership(role: TenantAccountRole = TenantAccountRole.NORMAL) -> TenantAccountJoin: + return TenantAccountJoin(tenant_id=TENANT_ID, account_id=ACCOUNT_ID, current=True, role=role) + + +def persist(session: Session, *models: object) -> None: + session.add_all(models) + session.commit() + + +def make_ctx(session: Session, subject: Subject | None = None, **view_args: str) -> Context: + return Context(subject or account_subject(), session, dict(view_args)) + + +def system_features( + *, + license_status: LicenseStatus = LicenseStatus.ACTIVE, + webapp_auth: bool = False, +) -> SystemFeatureModel: + return SystemFeatureModel( + deployment_edition=DeploymentEdition.ENTERPRISE, + license=LicenseStatusModel(status=license_status), + webapp_auth=WebAppAuthModel(enabled=webapp_auth), + ) + + +def webapp_settings(access_mode: str) -> WebAppSettings: + return WebAppSettings.model_validate({"accessMode": access_mode}) + + +def never_reached(*_args: object, **_kwargs: object) -> NoReturn: + """Stand-in for anything a test's own setup says the code under test must not reach.""" + raise AssertionError("reached a call this test's setup says nothing should have made") diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py deleted file mode 100644 index e77bb15c9cc..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ /dev/null @@ -1,223 +0,0 @@ -import uuid - -from controllers.common.rbac import PlainApp, RBACCheck -from controllers.openapi.auth.composition import account_pipeline, auth_router, external_sso_pipeline -from controllers.openapi.auth.data import RequestContext -from controllers.openapi.auth.flow import When -from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter -from controllers.openapi.auth.verify import ( - check_acl, - check_private_app_permission, - check_rbac_permission, - check_workspace_member, - check_workspace_mismatch, - check_workspace_role, -) -from core.rbac import RBACPermission -from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType -from models.account import TenantAccountRole -from services.enterprise.enterprise_service import WebAppAccessMode -from tests.unit_tests.config_override import config_overrides_context - - -def test_account_pipeline_is_auth_pipeline(): - assert isinstance(account_pipeline, AuthPipeline) - - -def test_external_sso_pipeline_is_auth_pipeline(): - assert isinstance(external_sso_pipeline, AuthPipeline) - - -def test_auth_router_is_pipeline_router(): - assert isinstance(auth_router, PipelineRouter) - - -def test_account_pipeline_prepare_has_six_entries(): - assert len(account_pipeline._prepare) == 6 - - -def test_account_auth_list_has_eight_entries(): - assert len(account_pipeline._auth) == 8 - - -def test_external_sso_pipeline_prepare_has_four_entries(): - assert len(external_sso_pipeline._prepare) == 4 - - -def test_external_sso_auth_list_has_four_entries(): - assert len(external_sso_pipeline._auth) == 4 - - -def test_account_pipeline_has_unconditional_load_account(): - non_when = [s for s in account_pipeline._prepare if not isinstance(s, When)] - assert len(non_when) == 1 - - -def test_external_sso_pipeline_all_prepare_entries_are_when(): - assert all(isinstance(s, When) for s in external_sso_pipeline._prepare) - - -def test_account_pipeline_has_one_unconditional_auth_step(): - non_when = [s for s in account_pipeline._auth if not isinstance(s, When)] - assert len(non_when) == 1 - - -def test_external_sso_pipeline_has_one_unconditional_auth_step(): - non_when = [s for s in external_sso_pipeline._auth if not isinstance(s, When)] - assert len(non_when) == 1 - - -def test_router_routes_contain_both_token_types(): - assert TokenType.OAUTH_ACCOUNT in auth_router._routes - assert TokenType.OAUTH_EXTERNAL_SSO in auth_router._routes - - -def test_external_sso_route_requires_enterprise_edition(): - route = auth_router._routes[TokenType.OAUTH_EXTERNAL_SSO] - assert isinstance(route, PipelineRoute) - assert route.required_edition == frozenset({DeploymentEdition.ENTERPRISE}) - - -def test_account_route_has_no_required_edition(): - route = auth_router._routes[TokenType.OAUTH_ACCOUNT] - assert isinstance(route, PipelineRoute) - assert route.required_edition is None - - -def _selected_auth_steps(*, app_id: bool, workspace_membership: bool, allowed_roles): - ctx = RequestContext( - token_type=TokenType.OAUTH_ACCOUNT, - scope=None, - path_params={"app_id": str(uuid.uuid4())} if app_id else {}, - workspace_membership=workspace_membership, - allowed_roles=allowed_roles, - ) - selected = [] - for step in account_pipeline._auth: - if isinstance(step, When): - if step.applies(ctx, None): - selected.append(step._step) - else: - selected.append(step) - return selected - - -_ALL_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.NORMAL}) - - -def test_workspace_path_selects_membership_check(): - steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=None) - assert check_workspace_member in steps - assert check_workspace_role not in steps - - -def test_app_path_selects_membership_check(): - steps = _selected_auth_steps(app_id=True, workspace_membership=False, allowed_roles=None) - assert check_workspace_member in steps - assert check_workspace_role not in steps - - -def test_roles_set_selects_both_membership_and_role_check(): - steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=_ALL_ROLES) - assert check_workspace_member in steps - assert check_workspace_role in steps - - -def test_plain_path_selects_no_membership_or_role_step(): - steps = _selected_auth_steps(app_id=False, workspace_membership=False, allowed_roles=None) - assert check_workspace_member not in steps - assert check_workspace_role not in steps - - -def test_app_path_selects_workspace_mismatch_check(): - steps = _selected_auth_steps(app_id=True, workspace_membership=False, allowed_roles=None) - assert check_workspace_mismatch in steps - - -def test_workspace_path_skips_workspace_mismatch_check(): - steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=None) - assert check_workspace_mismatch not in steps - - -def _selected_webapp_steps(*, scope, app_access_mode): - """Select auth steps for an EE, webapp-auth-enabled, app-scoped request. - - Patches the config-backed conditions (edition + webapp_auth) so the gating - reduces to PATH_HAS_APP_ID, LOADED_APP_IS_PRIVATE, and the request scope. - """ - from unittest.mock import patch - - from controllers.openapi.auth.data import AuthData - - ctx = RequestContext( - token_type=TokenType.OAUTH_ACCOUNT, - scope=scope, - path_params={"app_id": str(uuid.uuid4())}, - ) - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset({scope}) if scope is not None else frozenset(), - app_access_mode=app_access_mode, - ) - selected = [] - with ( - config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE), - patch("controllers.openapi.auth.conditions.SystemFeatureService.is_webapp_auth_enabled", return_value=True), - ): - for step in account_pipeline._auth: - if isinstance(step, When): - if step.applies(ctx, data): - selected.append(step._step) - else: - selected.append(step) - return selected - - -def test_apps_run_scope_selects_webapp_checks(): - steps = _selected_webapp_steps(scope=Scope.APPS_RUN, app_access_mode=WebAppAccessMode.PRIVATE) - assert check_acl in steps - assert check_private_app_permission in steps - - -def test_management_scope_skips_webapp_checks_on_private_app(): - # Export DSL et al. carry an app_id but use a management scope; the webapp - # end-user ACL / private-app gate must not block workspace members. - steps = _selected_webapp_steps(scope=Scope.APPS_READ, app_access_mode=WebAppAccessMode.PRIVATE) - assert check_acl not in steps - assert check_private_app_permission not in steps - - -def _selected_auth_steps_with_rbac(rbac): - ctx = RequestContext( - token_type=TokenType.OAUTH_ACCOUNT, - scope=Scope.APPS_READ, - path_params={"app_id": str(uuid.uuid4())}, - rbac=rbac, - ) - selected = [] - for step in account_pipeline._auth: - if isinstance(step, When): - if step.applies(ctx, None): - selected.append(step._step) - else: - selected.append(step) - return selected - - -def test_account_pipeline_selects_rbac_step_when_required(): - rbac = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()) - assert check_rbac_permission in _selected_auth_steps_with_rbac(rbac) - - -def test_account_pipeline_skips_rbac_step_without_requirement(): - assert check_rbac_permission not in _selected_auth_steps_with_rbac(None) - - -def test_external_sso_pipeline_never_enforces_rbac(): - # RBAC is a console (account) concern; external SSO callers are scope-gated. - rbac_steps = [ - s._step for s in external_sso_pipeline._auth if isinstance(s, When) and s._step is check_rbac_permission - ] - assert rbac_steps == [] - assert check_rbac_permission not in external_sso_pipeline._auth diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py deleted file mode 100644 index 8c3f1bc20ed..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ /dev/null @@ -1,208 +0,0 @@ -import uuid -from unittest.mock import patch - -from controllers.common.rbac import PlainApp, RBACCheck -from controllers.openapi.auth.conditions import ( - EDITION_CLOUD, - EDITION_COMMUNITY, - EDITION_ENTERPRISE, - HAS_ALLOWED_ROLES, - HAS_RBAC, - LOADED_APP_IS_PRIVATE, - PATH_HAS_APP_ID, - TOKEN_IS_OAUTH_ACCOUNT, - TOKEN_IS_OAUTH_EXTERNAL_SSO, - WEBAPP_AUTH_ENABLED, - WEBAPP_RUN_SCOPED, - WORKSPACE_MEMBERSHIP_REQUIRED, - Cond, - config_cond, - data_cond, - request_cond, -) -from controllers.openapi.auth.data import AuthData, RequestContext -from core.rbac import RBACPermission -from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType -from models.account import TenantAccountRole -from services.enterprise.enterprise_service import WebAppAccessMode -from tests.unit_tests.config_override import config_overrides_context - -PROBE_ID = uuid.uuid4() -OTHER_ID = uuid.uuid4() - - -def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None, **kwargs): - return RequestContext( - token_type=token_type, - path_params=path_params or {}, - **kwargs, - ) - - -def _data(**kwargs): - defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "scopes": frozenset()} - defaults.update(kwargs) - return AuthData(**defaults) - - -def test_and_both_true(): - a = Cond(lambda ctx, _: True) - b = Cond(lambda ctx, _: True) - assert (a & b)(_ctx()) is True - - -def test_and_one_false(): - a = Cond(lambda ctx, _: True) - b = Cond(lambda ctx, _: False) - assert (a & b)(_ctx()) is False - - -def test_or_one_true(): - a = Cond(lambda ctx, _: False) - b = Cond(lambda ctx, _: True) - assert (a | b)(_ctx()) is True - - -def test_or_both_false(): - a = Cond(lambda ctx, _: False) - b = Cond(lambda ctx, _: False) - assert (a | b)(_ctx()) is False - - -def test_invert(): - a = Cond(lambda ctx, _: True) - assert (~a)(_ctx()) is False - - -def test_chain_and_or(): - always_true = Cond(lambda ctx, _: True) - always_false = Cond(lambda ctx, _: False) - assert ((always_true | always_false) & always_true)(_ctx()) is True - - -def test_request_cond_ignores_data(): - c = request_cond(lambda ctx: ctx.token_type == TokenType.OAUTH_ACCOUNT) - assert c(_ctx(TokenType.OAUTH_ACCOUNT)) is True - assert c(_ctx(TokenType.OAUTH_EXTERNAL_SSO)) is False - - -def test_data_cond_returns_false_when_data_none(): - c = data_cond(lambda data: True) - assert c(_ctx(), None) is False - - -def test_data_cond_evaluates_when_data_present(): - c = data_cond(lambda data: data.token_id == PROBE_ID) - assert c(_ctx(), _data(token_id=PROBE_ID)) is True - assert c(_ctx(), _data(token_id=OTHER_ID)) is False - - -def test_config_cond_ignores_ctx_and_data(): - c = config_cond(lambda: True) - assert c(_ctx()) is True - c2 = config_cond(lambda: False) - assert c2(_ctx(), _data()) is False - - -def test_token_is_oauth_account(): - assert TOKEN_IS_OAUTH_ACCOUNT(_ctx(TokenType.OAUTH_ACCOUNT)) is True - assert TOKEN_IS_OAUTH_ACCOUNT(_ctx(TokenType.OAUTH_EXTERNAL_SSO)) is False - - -def test_token_is_oauth_external_sso(): - assert TOKEN_IS_OAUTH_EXTERNAL_SSO(_ctx(TokenType.OAUTH_EXTERNAL_SSO)) is True - - -def test_path_has_app_id_true(): - assert PATH_HAS_APP_ID(_ctx(path_params={"app_id": "abc"})) is True - - -def test_path_has_app_id_false(): - assert PATH_HAS_APP_ID(_ctx(path_params={})) is False - - -def test_edition_community(): - with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): - assert EDITION_COMMUNITY(_ctx()) is True - assert EDITION_ENTERPRISE(_ctx()) is False - assert EDITION_CLOUD(_ctx()) is False - - -def test_edition_enterprise(): - with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE): - assert EDITION_ENTERPRISE(_ctx()) is True - assert EDITION_COMMUNITY(_ctx()) is False - - -def test_edition_cloud(): - with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD): - assert EDITION_CLOUD(_ctx()) is True - - -def test_webapp_auth_enabled(): - with patch("controllers.openapi.auth.conditions.SystemFeatureService.is_webapp_auth_enabled", return_value=True): - assert WEBAPP_AUTH_ENABLED(_ctx()) is True - - -def test_webapp_run_scoped_true_for_apps_run(): - assert WEBAPP_RUN_SCOPED(_ctx(scope=Scope.APPS_RUN)) is True - - -def test_webapp_run_scoped_false_for_management_scope(): - assert WEBAPP_RUN_SCOPED(_ctx(scope=Scope.APPS_READ)) is False - - -def test_webapp_run_scoped_false_when_scope_none(): - assert WEBAPP_RUN_SCOPED(_ctx()) is False - - -def _rbac_req(): - return RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp()) - - -def test_has_rbac_true(): - assert HAS_RBAC(_ctx(rbac=_rbac_req())) is True - - -def test_has_rbac_false(): - assert HAS_RBAC(_ctx(rbac=None)) is False - - -def test_has_rbac_default(): - assert HAS_RBAC(_ctx()) is False - - -def test_loaded_app_is_private(): - data_private = _data(app_access_mode=WebAppAccessMode.PRIVATE) - data_public = _data(app_access_mode=WebAppAccessMode.PUBLIC) - data_none = _data(app_access_mode=None) - assert LOADED_APP_IS_PRIVATE(_ctx(), data_private) is True - assert LOADED_APP_IS_PRIVATE(_ctx(), data_public) is False - assert LOADED_APP_IS_PRIVATE(_ctx(), data_none) is False - assert LOADED_APP_IS_PRIVATE(_ctx(), None) is False - - -def test_workspace_membership_required_true(): - assert WORKSPACE_MEMBERSHIP_REQUIRED(_ctx(workspace_membership=True)) is True - - -def test_workspace_membership_required_false(): - assert WORKSPACE_MEMBERSHIP_REQUIRED(_ctx(workspace_membership=False)) is False - - -def test_workspace_membership_required_default(): - assert WORKSPACE_MEMBERSHIP_REQUIRED(_ctx()) is False - - -def test_has_allowed_roles_true(): - ctx = _ctx(allowed_roles=frozenset({TenantAccountRole.OWNER})) - assert HAS_ALLOWED_ROLES(ctx) is True - - -def test_has_allowed_roles_false(): - assert HAS_ALLOWED_ROLES(_ctx(allowed_roles=None)) is False - - -def test_has_allowed_roles_default(): - assert HAS_ALLOWED_ROLES(_ctx()) is False diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_context.py b/api/tests/unit_tests/controllers/openapi/auth/test_context.py new file mode 100644 index 00000000000..ae654e2f599 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_context.py @@ -0,0 +1,35 @@ +"""The store hands handlers what the route loaded, and nothing else.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from sqlalchemy.orm import Session + +from controllers.openapi.auth.context import Context, RouteContractError +from models.model import EndUser +from tests.unit_tests.controllers.openapi.auth._world import make_ctx + +READERS: dict[str, Callable[[Context], object]] = { + "app": lambda ctx: ctx.app, + "workspace": lambda ctx: ctx.workspace, + "workspace_role": lambda ctx: ctx.workspace_role, + "caller": lambda ctx: ctx.caller, + "account": lambda ctx: ctx.account, + "end_user": lambda ctx: ctx.end_user, +} + + +@pytest.mark.parametrize("read", READERS.values(), ids=READERS.keys()) +def test_an_unloaded_slot_is_an_error_not_a_fetch(sqlite_session: Session, read: Callable[[Context], object]) -> None: + ctx = make_ctx(sqlite_session) + with pytest.raises(RouteContractError, match="was not loaded"): + read(ctx) + + +def test_the_caller_is_read_as_what_it_is(sqlite_session: Session) -> None: + ctx = make_ctx(sqlite_session) + ctx._caller = EndUser() + with pytest.raises(RouteContractError, match="not the Account"): + _ = ctx.account diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_data.py b/api/tests/unit_tests/controllers/openapi/auth/test_data.py deleted file mode 100644 index 73aed7bd615..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_data.py +++ /dev/null @@ -1,141 +0,0 @@ -import uuid - -import pytest -from pydantic import ValidationError - -from controllers.openapi.auth.data import ( - AuthData, - ExternalIdentity, - RequestContext, -) -from libs.oauth_bearer import Scope, TokenType - - -def test_external_identity_frozen(): - ei = ExternalIdentity(email="a@b.com", issuer="idp") - with pytest.raises(ValidationError): - ei.email = "other@b.com" # type: ignore[misc] - - -def test_external_identity_issuer_optional(): - ei = ExternalIdentity(email="a@b.com") - assert ei.issuer is None - - -def test_request_context_frozen(): - ctx = RequestContext( - token_type=TokenType.OAUTH_ACCOUNT, - path_params={"app_id": "123"}, - ) - with pytest.raises(ValidationError): - ctx.token_type = TokenType.OAUTH_EXTERNAL_SSO # type: ignore[misc] - - -def test_request_context_scope_optional(): - ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}) - assert ctx.scope is None - - -def test_auth_data_is_mutable(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset({Scope.FULL}), - ) - data.token_type = TokenType.OAUTH_EXTERNAL_SSO - assert data.token_type == TokenType.OAUTH_EXTERNAL_SSO - - -def test_auth_data_path_params_defaults_empty(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset(), - ) - assert data.path_params == {} - - -def test_auth_data_account_id_optional(): - data = AuthData( - token_type=TokenType.OAUTH_EXTERNAL_SSO, - scopes=frozenset({Scope.APPS_RUN}), - external_identity=ExternalIdentity(email="u@sso.com"), - ) - assert data.account_id is None - - -def test_auth_data_external_identity_none_for_account(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=uuid.uuid4(), - scopes=frozenset({Scope.FULL}), - ) - assert data.external_identity is None - - -def test_auth_data_token_id_optional(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset(), - ) - assert data.token_id is None - - -def test_request_context_workspace_membership_default_false(): - ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}) - assert ctx.workspace_membership is False - - -def test_request_context_workspace_membership_set(): - ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}, workspace_membership=True) - assert ctx.workspace_membership is True - - -def test_request_context_allowed_roles_default_none(): - ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}) - assert ctx.allowed_roles is None - - -def test_request_context_allowed_roles_set(): - from models.account import TenantAccountRole - - roles = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}) - ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}, allowed_roles=roles) - assert ctx.allowed_roles == roles - - -def test_auth_data_allowed_roles_default_none(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset(), - ) - assert data.allowed_roles is None - - -def test_auth_data_allowed_roles_set(): - from models.account import TenantAccountRole - - roles = frozenset({TenantAccountRole.ADMIN}) - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset(), - allowed_roles=roles, - ) - assert data.allowed_roles == roles - - -def test_auth_data_tenant_role_default_none(): - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset(), - ) - assert data.tenant_role is None - - -def test_auth_data_tenant_role_set(): - from models.account import TenantAccountRole - - data = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - scopes=frozenset(), - tenant_role=TenantAccountRole.ADMIN, - ) - assert data.tenant_role == TenantAccountRole.ADMIN diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_flow.py b/api/tests/unit_tests/controllers/openapi/auth/test_flow.py deleted file mode 100644 index 1c87c3b6450..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_flow.py +++ /dev/null @@ -1,42 +0,0 @@ -import inspect - -from controllers.openapi.auth.conditions import Cond -from controllers.openapi.auth.data import AuthData, RequestContext -from controllers.openapi.auth.flow import When -from libs.oauth_bearer import TokenType - - -def _ctx(): - return RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}) - - -def _data(): - return AuthData(token_type=TokenType.OAUTH_ACCOUNT, scopes=frozenset()) - - -def test_applies_returns_true_when_condition_true(): - w = When(Cond(lambda ctx, _: True), then=lambda b: None) - assert w.applies(_ctx()) is True - - -def test_applies_returns_false_when_condition_false(): - w = When(Cond(lambda ctx, _: False), then=lambda b: None) - assert w.applies(_ctx()) is False - - -def test_applies_with_data(): - w = When(Cond(lambda ctx, data: data is not None), then=lambda b: None) - assert w.applies(_ctx(), _data()) is True - assert w.applies(_ctx(), None) is False - - -def test_call_invokes_step(): - calls = [] - w = When(Cond(lambda ctx, _: True), then=lambda arg: calls.append(arg)) - w("payload") - assert calls == ["payload"] - - -def test_then_is_keyword_only(): - sig = inspect.signature(When.__init__) - assert sig.parameters["then"].kind.name == "KEYWORD_ONLY" diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_loaders.py b/api/tests/unit_tests/controllers/openapi/auth/test_loaders.py new file mode 100644 index 00000000000..9c1d222b9ff --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_loaders.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import cast, override + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.loaders import ( + load_app, + load_end_user, + load_workspace, + load_workspace_role, +) +from controllers.openapi.auth.subjects import Subject +from models import App +from models.account import TenantAccountRole, TenantStatus +from services.account_service import TenantService +from services.app_service import AppService + +from ._world import ( + ACCOUNT_ID, + APP_ID, + OTHER_TENANT_ID, + TENANT_ID, + make_account, + make_app, + make_membership, + make_tenant, + never_reached, + persist, +) + + +class _StubSubject: + def __init__(self, caller: object | None = None, account_id: str = ACCOUNT_ID) -> None: + self.calls: list[tuple[object, Session]] = [] + self.caller = caller if caller is not None else object() + self.account_id = account_id + + def resolve_caller(self, ctx: object, session: Session) -> object: + self.calls.append((ctx, session)) + return self.caller + + +def _subject(caller: object | None = None) -> Subject: + """`_StubSubject` stands in for `Subject` structurally — the loaders only + ever call `resolve_caller` on it and read `account_id`. Cast once here + rather than annotating every `Context(...)` call site against the concrete + stub type. + """ + return cast(Subject, _StubSubject(caller)) + + +def _archived_app() -> App: + app = make_app() + app.status = "archived" # type: ignore[assignment] + return app + + +class TestLoadApp: + @pytest.mark.parametrize( + ("app_id", "fetched"), + [ + ("not-a-uuid", never_reached), + (APP_ID, lambda: None), + (APP_ID, _archived_app), + ], + ids=["malformed uuid", "missing", "not normal"], + ) + def test_404s_when_the_app_cannot_be_served( + self, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + app_id: str, + fetched: Callable[[], App | None], + ) -> None: + monkeypatch.setattr(AppService, "get_app_by_id", lambda *_a, **_k: fetched()) + ctx = Context(_subject(), sqlite_session, {"app_id": app_id}) + + with pytest.raises(NotFound, match="app not found"): + load_app(ctx) + + def test_a_bare_hex_app_id_names_the_same_app(self, sqlite_session: Session) -> None: + """The path parameter is whatever the caller typed; the row is keyed by the + canonical dashed form. Normalising here is what lets one spelling of a UUID + reach the app another spelling stored. + """ + persist(sqlite_session, make_app()) + ctx = Context(_subject(), sqlite_session, {"app_id": APP_ID.replace("-", "")}) + + assert load_app(ctx).id == APP_ID + + @pytest.mark.parametrize("persist_archived", [True, False]) + def test_forbidden_when_the_apps_tenant_is_missing_or_archived( + self, sqlite_session: Session, persist_archived: bool + ) -> None: + models: list[object] = [make_app()] + if persist_archived: + models.append(make_tenant(status=TenantStatus.ARCHIVE)) + persist(sqlite_session, *models) + ctx = Context(_subject(), sqlite_session, {"app_id": APP_ID}) + + with pytest.raises(Forbidden, match="workspace unavailable"): + load_workspace(ctx) + + +class TestWorkspaceFromRequest: + @pytest.mark.parametrize( + ("view_args", "persisted_status"), + [ + ({}, None), + ({"workspace_id": "not-a-uuid"}, None), + ({"workspace_id": TENANT_ID}, None), + ({"workspace_id": TENANT_ID}, TenantStatus.ARCHIVE), + ], + ids=["no workspace_id", "malformed workspace_id", "missing tenant", "archived tenant"], + ) + def test_not_found_when_the_workspace_cannot_be_served( + self, + app: Flask, + sqlite_session: Session, + view_args: dict[str, str], + persisted_status: TenantStatus | None, + ) -> None: + if persisted_status is not None: + persist(sqlite_session, make_tenant(status=persisted_status)) + ctx = Context(_subject(), sqlite_session, view_args) + + with app.test_request_context("/test"), pytest.raises(NotFound, match="workspace not found"): + load_workspace(ctx) + + +class TestWorkspaceRuleSelection: + def test_app_id_wins_the_tie_when_both_app_id_and_workspace_id_are_present( + self, app: Flask, sqlite_session: Session + ) -> None: + """Both a nonexistent app tenant and an existing workspace_id tenant are + present; `route_has_app` must select the app-derived rule (and its + `Forbidden` status), never fall through to the request-derived `NotFound` + rule. + """ + persist(sqlite_session, make_app(), make_tenant(tenant_id=OTHER_TENANT_ID)) + ctx = Context(_subject(), sqlite_session, {"app_id": APP_ID, "workspace_id": OTHER_TENANT_ID}) + + with app.test_request_context("/test"), pytest.raises(Forbidden, match="workspace unavailable"): + load_workspace(ctx) + + +class TestLoadWorkspaceRole: + def test_the_role_is_read_once_per_request(self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + """Membership and the RBAC role floor both call this; the request pays + for one SELECT, not one each. + """ + persist(sqlite_session, make_app(), make_tenant(), make_account(), make_membership(TenantAccountRole.ADMIN)) + calls: list[int] = [] + + def _counted(*_args: object, **_kwargs: object) -> TenantAccountRole: + calls.append(1) + return TenantAccountRole.ADMIN + + monkeypatch.setattr(TenantService, "get_account_role_in_tenant", _counted) + ctx = Context(_subject(make_account()), sqlite_session, {"app_id": APP_ID}) + + assert load_workspace_role(ctx) is TenantAccountRole.ADMIN + assert load_workspace_role(ctx) is TenantAccountRole.ADMIN + assert len(calls) == 1 + + def test_404s_a_caller_that_is_not_an_account( + self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch + ) -> None: + persist(sqlite_session, make_app(), make_tenant(), make_membership(TenantAccountRole.ADMIN)) + monkeypatch.setattr(TenantService, "get_account_role_in_tenant", never_reached) + ctx = Context(_subject(), sqlite_session, {"app_id": APP_ID}) + + with pytest.raises(NotFound, match="workspace not found"): + load_workspace_role(ctx) + + def test_loads_the_workspace_before_the_caller(self, sqlite_session: Session) -> None: + """A subject binds the account's current tenant while resolving it, so + the workspace has to be there already — loading the caller first would + leave the account mounted with no current tenant, silently. + """ + persist(sqlite_session, make_app(), make_tenant(), make_account(), make_membership()) + loaded_when_called: list[bool] = [] + + class _Recording(_StubSubject): + @override + def resolve_caller(self, ctx: object, session: Session) -> object: + loaded_when_called.append(cast(Context, ctx).workspace is not None) + return super().resolve_caller(ctx, session) + + ctx = Context(cast(Subject, _Recording(make_account())), sqlite_session, {"app_id": APP_ID}) + + load_workspace_role(ctx) + + assert loaded_when_called == [True] + + +class TestNarrowingLoaders: + def test_load_end_user_refuses_an_account(self, sqlite_session: Session) -> None: + ctx = Context(cast(Subject, _StubSubject(caller=make_account())), sqlite_session, {}) + + with pytest.raises(Forbidden, match="unsupported_token_type"): + load_end_user(ctx) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py deleted file mode 100644 index fc215fa4dd1..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py +++ /dev/null @@ -1,321 +0,0 @@ -import uuid -from unittest.mock import MagicMock, patch - -import pytest -from flask import Flask -from werkzeug.exceptions import Forbidden, NotFound, Unauthorized - -from controllers.openapi.auth.data import AuthData -from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter -from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType -from tests.unit_tests.config_override import config_overrides_context - - -def _make_identity( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=None, - scopes=None, - subject_email=None, - subject_issuer=None, - token_id=None, -): - identity = MagicMock() - identity.token_type = token_type - identity.account_id = account_id or uuid.uuid4() - identity.scopes = scopes or frozenset({Scope.FULL}) - identity.subject_email = subject_email - identity.subject_issuer = subject_issuer - identity.token_id = token_id or uuid.uuid4() - return identity - - -@pytest.fixture -def app(): - return Flask(__name__) - - -def _make_router(token_type=TokenType.OAUTH_ACCOUNT, prepare=None, auth=None): - pipeline = AuthPipeline(prepare=prepare or [], auth=auth or []) - return PipelineRouter({token_type: PipelineRoute(pipeline)}) - - -def _fake_identity(): - return _make_identity() - - -# --- PipelineRouter.guard --- - - -def test_guard_passes_auth_data_to_view(app): - router = _make_router() - received = {} - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx"), - ): - mock_auth.return_value.authenticate.return_value = _fake_identity() - - @router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - def view(*, auth_data): - received["data"] = auth_data - - view() - - assert isinstance(received["data"], AuthData) - - -def test_guard_edition_gate_returns_404(app): - router = _make_router() - - with app.test_request_context("/test"): - with config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY): - - @router.guard(scope=Scope.FULL, edition=frozenset({DeploymentEdition.ENTERPRISE})) - def view(*, auth_data): - pass - - with pytest.raises(NotFound): - view() - - -def test_guard_token_type_gate_returns_403(app): - router = _make_router() - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.emit_wrong_surface"), - config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), - ): - identity = _fake_identity() - identity.token_type = TokenType.OAUTH_EXTERNAL_SSO - mock_auth.return_value.authenticate.return_value = identity - - @router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - def view(*, auth_data): - pass - - with pytest.raises(Forbidden): - view() - - -def test_guard_unregistered_token_type_returns_403(app): - router = _make_router(token_type=TokenType.OAUTH_ACCOUNT) - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), - ): - identity = _fake_identity() - identity.token_type = TokenType.OAUTH_EXTERNAL_SSO - mock_auth.return_value.authenticate.return_value = identity - - @router.guard(scope=Scope.FULL) - def view(*, auth_data): - pass - - with pytest.raises(Forbidden): - view() - - -def test_guard_no_bearer_returns_401(app): - router = _make_router() - - with app.test_request_context("/test"): - with patch("controllers.openapi.auth.pipeline.extract_bearer", return_value=None): - - @router.guard(scope=Scope.FULL) - def view(*, auth_data): - pass - - with pytest.raises(Unauthorized): - view() - - -def test_guard_runs_prepare_steps_in_order(app): - order = [] - - def p1(b): - order.append("p1") - - def p2(b): - order.append("p2") - - router = _make_router(prepare=[p1, p2]) - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx"), - ): - mock_auth.return_value.authenticate.return_value = _fake_identity() - - @router.guard(scope=Scope.FULL) - def view(*, auth_data): - pass - - view() - - assert order == ["p1", "p2"] - - -def test_guard_resets_auth_ctx_on_exception(app): - router = _make_router() - reset_called = [] - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value="tok"), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx", side_effect=lambda t: reset_called.append(t)), - ): - mock_auth.return_value.authenticate.return_value = _fake_identity() - - @router.guard(scope=Scope.FULL) - def view(*, auth_data): - raise RuntimeError("boom") - - with pytest.raises(RuntimeError): - view() - - assert reset_called == ["tok"] - - -def test_router_rejects_token_type_on_wrong_edition(app): - pipeline = AuthPipeline(prepare=[], auth=[]) - route = PipelineRoute(pipeline, required_edition=frozenset({DeploymentEdition.ENTERPRISE})) - router = PipelineRouter({TokenType.OAUTH_EXTERNAL_SSO: route}) - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY), - ): - identity = _make_identity(token_type=TokenType.OAUTH_EXTERNAL_SSO) - mock_auth.return_value.authenticate.return_value = identity - - @router.guard(scope=Scope.APPS_RUN) - def view(*, auth_data): - pass - - with pytest.raises(Forbidden): - view() - - -def test_guard_populates_external_identity_from_subject_email(app): - from controllers.openapi.auth.data import ExternalIdentity - - router = _make_router(token_type=TokenType.OAUTH_EXTERNAL_SSO) - received = {} - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx"), - ): - identity = _make_identity( - token_type=TokenType.OAUTH_EXTERNAL_SSO, - subject_email="user@sso.com", - subject_issuer="https://idp.example.com", - ) - mock_auth.return_value.authenticate.return_value = identity - - @router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO})) - def view(*, auth_data): - received["data"] = auth_data - - view() - - assert isinstance(received["data"].external_identity, ExternalIdentity) - assert received["data"].external_identity.email == "user@sso.com" - assert received["data"].external_identity.issuer == "https://idp.example.com" - - -def test_guard_workspace_sets_membership_and_roles(app): - from models.account import TenantAccountRole - - router = _make_router() - received = {} - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx"), - ): - mock_auth.return_value.authenticate.return_value = _fake_identity() - - roles = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}) - - @router.guard_workspace( - scope=Scope.FULL, - allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}), - allowed_roles=roles, - ) - def view(*, auth_data): - received["data"] = auth_data - - view() - - assert isinstance(received["data"], AuthData) - assert received["data"].allowed_roles == roles - - -def test_guard_workspace_without_roles(app): - router = _make_router() - received = {} - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx"), - ): - mock_auth.return_value.authenticate.return_value = _fake_identity() - - @router.guard_workspace(scope=Scope.FULL) - def view(*, auth_data): - received["data"] = auth_data - - view() - - assert isinstance(received["data"], AuthData) - assert received["data"].allowed_roles is None - - -def test_guard_no_external_identity_when_subject_email_absent(app): - router = _make_router() - received = {} - - with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): - with ( - patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), - patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()), - patch("controllers.openapi.auth.pipeline.reset_auth_ctx"), - ): - mock_auth.return_value.authenticate.return_value = _make_identity(subject_email=None) - - @router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) - def view(*, auth_data): - received["data"] = auth_data - - view() - - assert received["data"].external_identity is None diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py new file mode 100644 index 00000000000..283542def02 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipelines.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import override +from unittest.mock import patch + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden, Unauthorized + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.pipelines import ( + _PIPELINES, + AccountPipeline, + ExternalSsoPipeline, + Pipeline, +) +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckSubject, + CheckWorkspaceMember, + Rank, + Requirement, +) +from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.subjects import _SUBJECT_CLASSES, AccountSubject, Subject +from enums import DeploymentEdition +from libs.oauth_bearer import AuthContext, try_get_auth_ctx +from services.account_service import AccountService, TenantService +from services.app_service import AppService +from services.end_user_service import EndUserService +from services.enterprise.enterprise_service import WebAppAccessMode + +from ._world import ( + APP_ID, + account_subject, + make_account, + make_app, + make_ctx, + make_membership, + make_tenant, + never_reached, + persist, + sso_subject, + system_features, + webapp_settings, +) + +MOUNT = "controllers.openapi.auth.pipelines._mount_flask_login" +FEATURES = "controllers.openapi.auth.requirements.SystemFeatureService.get_public_system_features" +ACCESS_MODE = "controllers.openapi.auth.requirements.EnterpriseService.WebAppAuth.get_app_access_mode_by_id" + + +class _Recorded(Requirement): + """Shared plumbing for the ordering test doubles. Declares no rank of its + own, so a bare instance proves `Requirement`'s default applies. + """ + + def __init__(self, log: list[str], name: str) -> None: + self._log = log + self._name = name + + @override + def run(self, subject: Subject, ctx: Context, session: Session) -> None: + self._log.append(self._name) + + +class _AtFirst(_Recorded): + rank = Rank.FIRST + + +class _AtEarly(_Recorded): + rank = Rank.EARLY + + +class _NoFixed(Pipeline): + pass + + +def _run( + pipeline: Pipeline, + subject: Subject, + ctx: Context, + session: Session, + *, + requirements: tuple[Requirement, ...] = (), + call: Callable[..., object] = lambda **_kwargs: None, +) -> object: + return pipeline.run( + subject=subject, + auth=subject.auth, + spec=EndpointSpec(requirements=requirements), + ctx=ctx, + session=session, + call=call, + ) + + +def test_requirements_run_in_rank_order(sqlite_session: Session) -> None: + log: list[str] = [] + subject = sso_subject() + requirements = (_Recorded(log, "normal"), _AtEarly(log, "early"), _AtFirst(log, "first")) + + _run(_NoFixed(), subject, make_ctx(sqlite_session, subject), sqlite_session, requirements=requirements) + + assert log == ["first", "early", "normal"] + + +def test_equal_ranks_keep_declared_order(sqlite_session: Session) -> None: + """Endpoint-declared before pipeline-fixed at equal rank — the property + that keeps `CheckSubject` ahead of `_RequiresEnterprise`, and the reason the sort + has to stay stable. + """ + log: list[str] = [] + + class _FixedRecorders(Pipeline): + fixed = (_Recorded(log, "fixed-a"), _Recorded(log, "fixed-b")) + + subject = sso_subject() + requirements = (_Recorded(log, "spec-a"), _Recorded(log, "spec-b")) + + _run(_FixedRecorders(), subject, make_ctx(sqlite_session, subject), sqlite_session, requirements=requirements) + + assert log == ["spec-a", "spec-b", "fixed-a", "fixed-b"] + + +@pytest.mark.parametrize("view_raises", [False, True]) +def test_auth_ctx_is_published_for_the_view_and_reset_after_it( + sqlite_session: Session, + view_raises: bool, +) -> None: + subject = sso_subject() + seen: list[AuthContext | None] = [] + + def call(**_kwargs: object) -> None: + seen.append(try_get_auth_ctx()) + if view_raises: + raise RuntimeError("boom") + + ctx = make_ctx(sqlite_session, subject) + if view_raises: + with pytest.raises(RuntimeError): + _run(_NoFixed(), subject, ctx, sqlite_session, call=call) + else: + _run(_NoFixed(), subject, ctx, sqlite_session, call=call) + + assert seen == [subject.auth] + assert try_get_auth_ctx() is None + + +def test_a_caller_that_cannot_be_resolved_leaves_the_auth_ctx_unset( + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A token outliving its account raises inside `ResolveCaller`, which is a + requirement and so runs before `mounted`. Resolving after `set_auth_ctx` + would strand the identity on the ContextVar that `libs/rate_limit` buckets + on, with no reset to undo it. + """ + monkeypatch.setattr(MOUNT, never_reached) + subject = account_subject() + + with pytest.raises(Unauthorized, match="account not found"): + _run(AccountPipeline(), subject, make_ctx(sqlite_session, subject), sqlite_session) + + assert try_get_auth_ctx() is None + + +def test_the_requirements_that_share_a_datum_fetch_it_once( + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Both declared requirements need the app — `CheckAppApiEnabled` directly, + `CheckWorkspaceMember` through the workspace it hangs off — and the + membership check and `ResolveCaller` both need the caller. Each is fetched once. + """ + persist(sqlite_session, make_app(), make_tenant(), make_account(), make_membership()) + monkeypatch.setattr(MOUNT, lambda _user: None) + subject = account_subject() + + with ( + patch.object(AppService, "get_app_by_id", wraps=AppService.get_app_by_id) as app_fetch, + patch.object(TenantService, "get_tenant_by_id", wraps=TenantService.get_tenant_by_id) as workspace_fetch, + patch.object(AccountService, "get_account_by_id", wraps=AccountService.get_account_by_id) as caller_fetch, + ): + _run( + AccountPipeline(), + subject, + make_ctx(sqlite_session, subject, app_id=APP_ID), + sqlite_session, + requirements=(CheckAppApiEnabled(), CheckWorkspaceMember()), + ) + + assert (app_fetch.call_count, workspace_fetch.call_count, caller_fetch.call_count) == (1, 1, 1) + + +@pytest.mark.parametrize( + ("requirements", "enable_api", "webapp_auth", "message"), + [ + ((CheckSubject(allowed=[AccountSubject]),), True, False, "unsupported_token_type"), + ((CheckAppApiEnabled(),), False, False, "service_api_disabled"), + ((CheckAppAccess(),), True, True, "subject_not_allowed_for_access_mode"), + ], + ids=["wrong subject (FIRST)", "api disabled (EARLY)", "webapp acl (NORMAL)"], +) +def test_a_refused_sso_request_never_creates_an_end_user( + app: Flask, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], + requirements: tuple[Requirement, ...], + enable_api: bool, + webapp_auth: bool, + message: str, +) -> None: + """`ResolveCaller` mints an `EndUser` row, so it has to run after every + requirement that can refuse — one refusal per band, because a rank that + moved it earlier would side-effect before the gate that exists to stop it. + """ + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + persist(sqlite_session, make_app(enable_api=enable_api), make_tenant()) + monkeypatch.setattr(MOUNT, never_reached) + monkeypatch.setattr(EndUserService, "get_or_create_end_user_by_type", never_reached) + subject = sso_subject() + ctx = make_ctx(sqlite_session, subject, app_id=APP_ID) + + with app.test_request_context(f"/openapi/v1/apps/{APP_ID}:run"): + with patch(FEATURES, return_value=system_features(webapp_auth=webapp_auth)): + with patch(ACCESS_MODE, return_value=webapp_settings(WebAppAccessMode.PRIVATE_ALL.value)): + with pytest.raises(Forbidden, match=message): + _run(ExternalSsoPipeline(), subject, ctx, sqlite_session, requirements=requirements) + + assert ctx._caller is None + + +def test_every_registrable_subject_has_a_pipeline() -> None: + assert set(_SUBJECT_CLASSES.values()) == set(_PIPELINES) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py b/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py deleted file mode 100644 index fa844520e3e..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_prepare.py +++ /dev/null @@ -1,346 +0,0 @@ -import uuid -from types import SimpleNamespace -from unittest.mock import PropertyMock, patch - -import pytest -from flask import Flask -from sqlalchemy import Engine, select -from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized - -from controllers.openapi.auth.data import AuthData, ExternalIdentity -from controllers.openapi.auth.prepare import ( - load_account, - load_app, - load_app_access_mode, - load_tenant, - load_tenant_from_request, - load_workspace_role, - resolve_external_user, -) -from libs.oauth_bearer import TokenType -from models import Account, App, EndUser, Tenant, TenantAccountJoin -from models.account import AccountStatus, TenantAccountRole, TenantStatus -from services import end_user_service -from services.enterprise.enterprise_service import WebAppAccessMode -from tests.unit_tests.model_factories import make_account, make_app, make_tenant - -APP_ID = "00000000-0000-0000-0000-000000000001" -TENANT_ID = "00000000-0000-0000-0000-000000000002" -ACCOUNT_ID = "00000000-0000-0000-0000-000000000003" - - -def _make_auth_data(**kwargs: object) -> AuthData: - return AuthData( - token_type=kwargs.pop("token_type", TokenType.OAUTH_ACCOUNT), - scopes=kwargs.pop("scopes", frozenset()), - **kwargs, - ) - - -def _app( - *, - app_id: str = APP_ID, - tenant_id: str = TENANT_ID, - enable_api: bool = True, -) -> App: - return make_app(app_id=app_id, tenant_id=tenant_id, name="OpenAPI app", enable_api=enable_api) - - -def _tenant(*, tenant_id: str = TENANT_ID, status: TenantStatus = TenantStatus.NORMAL) -> Tenant: - return make_tenant(tenant_id=tenant_id, name="OpenAPI tenant", status=status) - - -def _account(*, status: AccountStatus = AccountStatus.ACTIVE) -> Account: - return make_account(account_id=ACCOUNT_ID, name="OpenAPI account", email="account@example.com", status=status) - - -def _persist(session: Session, *models: object) -> None: - session.add_all(models) - session.commit() - - -class TestLoadApp: - def test_writes_persisted_app_to_data(self, sqlite_session: Session) -> None: - _persist(sqlite_session, _app()) - data = _make_auth_data(path_params={"app_id": APP_ID}) - - load_app(data) - - assert data.app is not None - assert data.app.id == APP_ID - - def test_rejects_non_uuid_and_missing_app(self) -> None: - with pytest.raises(NotFound, match="app not found"): - load_app(_make_auth_data(path_params={"app_id": "not-a-uuid"})) - with pytest.raises(NotFound, match="app not found"): - load_app(_make_auth_data(path_params={"app_id": APP_ID})) - - def test_rejects_non_normal_app(self) -> None: - app = _app() - app.status = "archived" # type: ignore[assignment] - - with ( - patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=app), - pytest.raises(NotFound, match="app not found"), - ): - load_app(_make_auth_data(path_params={"app_id": APP_ID})) - - def test_stashes_app_even_when_api_disabled(self, sqlite_session: Session) -> None: - _persist(sqlite_session, _app(enable_api=False)) - data = _make_auth_data(path_params={"app_id": APP_ID}) - - load_app(data) - - assert data.app is not None - assert data.app.enable_api is False - - def test_skips_when_already_set(self) -> None: - existing_app = _app() - data = _make_auth_data(app=existing_app, path_params={"app_id": "invalid"}) - - load_app(data) - - assert data.app is existing_app - - -class TestLoadTenant: - def test_writes_persisted_tenant(self, sqlite_session: Session) -> None: - app = _app() - _persist(sqlite_session, app, _tenant()) - data = _make_auth_data(app=app) - - load_tenant(data) - - assert data.tenant is not None - assert data.tenant.id == TENANT_ID - - def test_skips_when_already_set(self) -> None: - tenant = _tenant() - data = _make_auth_data(app=_app(), tenant=tenant) - - load_tenant(data) - - assert data.tenant is tenant - - @pytest.mark.parametrize("persist_archived", [True, False]) - def test_rejects_archived_or_missing_tenant(self, sqlite_session: Session, persist_archived: bool) -> None: - app = _app() - models: list[object] = [app] - if persist_archived: - models.append(_tenant(status=TenantStatus.ARCHIVE)) - _persist(sqlite_session, *models) - - with pytest.raises(Forbidden, match="workspace unavailable"): - load_tenant(_make_auth_data(app=app)) - - def test_rejects_missing_app_context(self) -> None: - with pytest.raises(InternalServerError, match="app not loaded"): - load_tenant(_make_auth_data()) - - -class TestLoadAccount: - def test_writes_persisted_caller(self, sqlite_session: Session) -> None: - _persist(sqlite_session, _account()) - data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID)) - - load_account(data) - - assert data.caller is not None - assert data.caller.id == ACCOUNT_ID - assert data.caller_kind == "account" - - def test_sets_current_tenant_from_real_membership(self, sqlite_session: Session) -> None: - account = _account() - tenant = _tenant() - membership = TenantAccountJoin( - tenant_id=tenant.id, - account_id=account.id, - current=True, - role=TenantAccountRole.ADMIN, - ) - _persist(sqlite_session, account, tenant, membership) - data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID), tenant=tenant) - - load_account(data) - - assert isinstance(data.caller, Account) - assert data.caller.current_tenant_id == TENANT_ID - assert data.caller.role == TenantAccountRole.ADMIN - - def test_skips_when_caller_already_set(self) -> None: - account = _account() - data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID), caller=account) - - load_account(data) - - assert data.caller is account - - def test_rejects_missing_account(self) -> None: - with pytest.raises(Unauthorized, match="account not found"): - load_account(_make_auth_data(account_id=uuid.UUID(ACCOUNT_ID))) - - -class TestResolveExternalUser: - def test_persists_and_writes_end_user( - self, - sqlite_engine: Engine, - sqlite_session: Session, - ) -> None: - app = _app() - tenant = _tenant() - _persist(sqlite_session, app, tenant) - data = _make_auth_data( - tenant=tenant, - app=app, - external_identity=ExternalIdentity(email="user@sso.com"), - ) - - with patch.object(type(end_user_service.db), "engine", new_callable=PropertyMock) as engine: - engine.return_value = sqlite_engine - resolve_external_user(data) - - assert isinstance(data.caller, EndUser) - assert data.caller_kind == "end_user" - with Session(sqlite_engine) as observer: - persisted = observer.scalar(select(EndUser).where(EndUser.session_id == "user@sso.com")) - assert persisted is not None - assert persisted.tenant_id == TENANT_ID - assert persisted.app_id == APP_ID - - def test_rejects_missing_context(self) -> None: - data = _make_auth_data(app=_app(), external_identity=ExternalIdentity(email="u@s.com")) - - with pytest.raises(Unauthorized, match="missing context"): - resolve_external_user(data) - - -class TestLoadAppAccessMode: - def test_writes_mode(self) -> None: - data = _make_auth_data(app=_app()) - settings = SimpleNamespace(access_mode="public") - - with patch( - "controllers.openapi.auth.prepare.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", - return_value=settings, - ): - load_app_access_mode(data) - - assert data.app_access_mode == WebAppAccessMode.PUBLIC - - def test_writes_none_when_provider_raises(self) -> None: - data = _make_auth_data(app=_app()) - with patch( - "controllers.openapi.auth.prepare.EnterpriseService.WebAppAuth.get_app_access_mode_by_id", - side_effect=ValueError("No data found."), - ): - load_app_access_mode(data) - assert data.app_access_mode is None - - def test_noop_without_app(self) -> None: - data = _make_auth_data() - load_app_access_mode(data) - assert data.app_access_mode is None - - -class TestLoadTenantFromRequest: - def test_loads_from_path_or_query( - self, - app: Flask, - sqlite_session: Session, - ) -> None: - _persist(sqlite_session, _tenant()) - for path_params, path in (({"workspace_id": TENANT_ID}, "/test"), ({}, f"/test?workspace_id={TENANT_ID}")): - data = _make_auth_data(path_params=path_params) - with app.test_request_context(path): - load_tenant_from_request(data) - assert data.tenant is not None - assert data.tenant.id == TENANT_ID - - def test_skips_when_already_set(self, app: Flask) -> None: - tenant = _tenant() - data = _make_auth_data(tenant=tenant) - with app.test_request_context("/test"): - load_tenant_from_request(data) - assert data.tenant is tenant - - def test_rejects_missing_or_invalid_id(self, app: Flask) -> None: - for path_params in ({}, {"workspace_id": "not-a-uuid"}): - with app.test_request_context("/test"), pytest.raises(NotFound, match="workspace not found"): - load_tenant_from_request(_make_auth_data(path_params=path_params)) - - @pytest.mark.parametrize("tenant_status", [None, TenantStatus.ARCHIVE]) - def test_rejects_missing_or_archived_tenant( - self, - app: Flask, - sqlite_session: Session, - tenant_status: TenantStatus | None, - ) -> None: - if tenant_status is not None: - _persist(sqlite_session, _tenant(status=tenant_status)) - data = _make_auth_data(path_params={"workspace_id": TENANT_ID}) - - with app.test_request_context("/test"), pytest.raises(NotFound, match="workspace not found"): - load_tenant_from_request(data) - - -class TestLoadWorkspaceRole: - def test_loads_real_membership_role(self, sqlite_session: Session) -> None: - account = _account() - tenant = _tenant() - membership = TenantAccountJoin( - tenant_id=tenant.id, - account_id=account.id, - current=True, - role=TenantAccountRole.ADMIN, - ) - _persist(sqlite_session, account, tenant, membership) - data = _make_auth_data( - account_id=uuid.UUID(ACCOUNT_ID), - tenant=tenant, - caller=account, - ) - - load_workspace_role(data) - - assert data.tenant_role == TenantAccountRole.ADMIN - - def test_none_when_not_member(self, sqlite_session: Session) -> None: - account = _account() - tenant = _tenant() - _persist(sqlite_session, account, tenant) - data = _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID), tenant=tenant, caller=account) - - load_workspace_role(data) - - assert data.tenant_role is None - - def test_none_when_account_inactive(self) -> None: - data = _make_auth_data( - account_id=uuid.UUID(ACCOUNT_ID), - tenant=_tenant(), - caller=_account(status=AccountStatus.BANNED), - ) - load_workspace_role(data) - assert data.tenant_role is None - - def test_skips_when_already_set(self) -> None: - data = _make_auth_data( - account_id=uuid.UUID(ACCOUNT_ID), - tenant=_tenant(), - caller=_account(), - tenant_role=TenantAccountRole.OWNER, - ) - load_workspace_role(data) - assert data.tenant_role == TenantAccountRole.OWNER - - @pytest.mark.parametrize( - "data", - [ - _make_auth_data(account_id=uuid.UUID(ACCOUNT_ID)), - _make_auth_data(tenant=_tenant(), account_id=None), - ], - ) - def test_skips_without_tenant_or_account(self, data: AuthData) -> None: - load_workspace_role(data) - assert data.tenant_role is None diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py b/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py new file mode 100644 index 00000000000..75505a94a15 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from collections.abc import Callable +from unittest.mock import patch + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission +from controllers.openapi.auth.context import RouteContractError +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckRBACPermission, + CheckSubject, + CheckWorkspaceRole, + assert_license_valid, +) +from controllers.openapi.auth.subjects import AccountSubject +from enums import DeploymentEdition +from models.account import AccountStatus, TenantAccountRole +from services.enterprise.enterprise_service import WebAppAccessMode, WebAppSettings +from services.entities.feature_entities import ( + LicenseStatus, +) + +from ._world import ( + APP_ID, + CLIENT_ID, + TOKEN_ID, + account_subject, + make_account, + make_app, + make_ctx, + make_membership, + make_tenant, + never_reached, + persist, + sso_subject, + system_features, + webapp_settings, +) + +FEATURES = "controllers.openapi.auth.requirements.SystemFeatureService.get_public_system_features" +WEBAPP_AUTH = "controllers.openapi.auth.requirements.EnterpriseService.WebAppAuth" +ACCESS_MODE = f"{WEBAPP_AUTH}.get_app_access_mode_by_id" +ENFORCE_RBAC = "controllers.openapi.auth.requirements.enforce_rbac_checks" +APP_FETCH = "controllers.openapi.auth.loaders.AppService.get_app_by_id" + + +def test_subject_check_emits_the_wrong_surface_audit(app: Flask, sqlite_session: Session) -> None: + subject = sso_subject() + + with app.test_request_context("/openapi/v1/account"): + with patch("controllers.openapi.auth.requirements.emit_wrong_surface") as emit: + requirement = CheckSubject(allowed=[AccountSubject]) + with pytest.raises(Forbidden, match="unsupported_token_type"): + requirement.run(subject, make_ctx(sqlite_session, subject=subject), sqlite_session) + + emit.assert_called_once_with( + subject_type="external_sso", + attempted_path="/openapi/v1/account", + client_id=CLIENT_ID, + token_id=TOKEN_ID, + ) + + +@pytest.mark.parametrize( + ("status", "denied"), + [ + (LicenseStatus.INACTIVE, True), + (LicenseStatus.LOST, True), + (LicenseStatus.NONE, False), + ], +) +def test_assert_license_valid_denies_only_dead_licences(status: LicenseStatus, denied: bool) -> None: + with patch(FEATURES, return_value=system_features(license_status=status)): + if denied: + with pytest.raises(Forbidden, match="license_invalid"): + assert_license_valid() + else: + assert_license_valid() + + +def test_an_app_requirement_off_an_app_route_is_a_wiring_bug( + sqlite_session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """App requirements are declared per endpoint, so a route with no `app_id` + can only carry one by mistake. It raises rather than passing quietly: + skipping would turn a misdeclaration into a check that silently never runs. + """ + monkeypatch.setattr(APP_FETCH, never_reached) + subject = account_subject() + + with pytest.raises(RouteContractError, match="app_id is not a path parameter"): + CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject), sqlite_session) + + +class TestCheckRBACPermission: + @staticmethod + def _requirement() -> CheckRBACPermission: + return CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())) + + def test_skips_a_non_account_caller(self, sqlite_session: Session, config_overrides: Callable[..., None]) -> None: + """No matrix row reaches this: on the routes an SSO token can address, + RBAC permission either does not apply or no permission is declared, so an SSO caller falling + into account-scoped RBAC is invisible there. + """ + config_overrides(RBAC_ENABLED=True) + subject = sso_subject() + + with patch(ENFORCE_RBAC) as enforce: + self._requirement().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session) + + enforce.assert_not_called() + + def test_is_inert_wherever_rbac_is_off( + self, app: Flask, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: + """No matrix row reaches this: every row runs against a stubbed RBAC + backend, so a permission that enforced where RBAC is switched off would still + be admitted there. Standing down is what leaves the `CheckWorkspaceRole` + beside it as the only arm there. + """ + config_overrides(RBAC_ENABLED=False) + persist(sqlite_session, make_app(), make_tenant(), make_account()) + subject = account_subject() + ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID) + + with app.test_request_context(f"/openapi/v1/apps/{APP_ID}"): + with patch(ENFORCE_RBAC) as enforce: + self._requirement().run(subject, ctx, sqlite_session) + + enforce.assert_not_called() + + +class TestCheckWorkspaceRole: + admin_only = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}) + + def test_skips_a_non_account_caller(self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + """No matrix row reaches this either — and the workspace role check must not even fetch + the app to decide it. + """ + monkeypatch.setattr(APP_FETCH, never_reached) + subject = sso_subject() + + CheckWorkspaceRole(self.admin_only).run( + subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session + ) + + def test_the_workspace_role_reads_the_role_through_the_loader( + self, app: Flask, sqlite_session: Session, config_overrides: Callable[..., None] + ) -> None: + """The answer is `load_workspace_role`'s, not a direct role read's: an + account that is not `ACTIVE` is a non-member, and hears 404 rather than + the workspace role check's own 403. + + No matrix row reaches this. An `EARLY` membership check pre-empts the + `NORMAL` workspace role check on every shipped route, and the matrix mints only `ACTIVE` + accounts — so this is the only thing standing between the workspace role check and a + refactor that calls `get_account_role_in_tenant` itself, which would + re-admit a banned admin and answer 403 where the surface answers 404. + """ + config_overrides(RBAC_ENABLED=False) + persist( + sqlite_session, + make_app(), + make_tenant(), + make_account(status=AccountStatus.BANNED), + make_membership(TenantAccountRole.ADMIN), + ) + subject = account_subject() + ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID) + + with app.test_request_context(f"/openapi/v1/apps/{APP_ID}"): + with pytest.raises(NotFound, match="workspace not found"): + CheckWorkspaceRole(self.admin_only).run(subject, ctx, sqlite_session) + + +class TestCheckAppAccess: + @pytest.fixture(autouse=True) + def _enterprise(self, config_overrides: Callable[..., None]) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + + def test_no_ops_outside_enterprise( + self, sqlite_session: Session, config_overrides: Callable[..., None], monkeypatch: pytest.MonkeyPatch + ) -> None: + """The named guard for the CE contract: the access-mode service is never + reached at all. Dropping the early return is otherwise only caught + incidentally, by an unrelated test that happens not to stub it. + """ + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + monkeypatch.setattr(ACCESS_MODE, never_reached) + persist(sqlite_session, make_app()) + subject = account_subject() + + CheckAppAccess().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session) + + @pytest.mark.parametrize( + ("settings", "failure"), + [ + (webapp_settings("a-mode-this-build-has-never-heard-of"), None), + (None, ValueError("enterprise said no")), + ], + ids=["unknown mode", "service error"], + ) + def test_rejects_an_access_mode_it_could_not_load( + self, sqlite_session: Session, settings: WebAppSettings | None, failure: Exception | None + ) -> None: + persist(sqlite_session, make_app()) + subject = account_subject() + ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID) + + with patch(FEATURES, return_value=system_features(webapp_auth=True)): + with patch(ACCESS_MODE, return_value=settings, side_effect=failure): + with pytest.raises(Forbidden, match="app or access mode not loaded"): + CheckAppAccess().run(subject, ctx, sqlite_session) + + def test_refuses_a_private_app_when_the_user_cannot_be_resolved(self, sqlite_session: Session) -> None: + persist(sqlite_session, make_app()) + subject = account_subject(account_id=None) + ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID) + + with patch(FEATURES, return_value=system_features(webapp_auth=True)): + with patch(ACCESS_MODE, return_value=webapp_settings(WebAppAccessMode.PRIVATE.value)): + with pytest.raises(Forbidden, match="cannot resolve user for private app check"): + CheckAppAccess().run(subject, ctx, sqlite_session) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_router.py b/api/tests/unit_tests/controllers/openapi/auth/test_router.py new file mode 100644 index 00000000000..14afc86d260 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_router.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import NoReturn +from unittest.mock import MagicMock + +import pytest +from flask import Flask +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized + +import libs.rate_limit as rate_limit_module +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import Requirement +from controllers.openapi.auth.router import subject_router +from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.subjects import AccountSubject +from enums import DeploymentEdition +from libs.oauth_bearer import ( + AuthContext, + BearerAuthenticator, + OAuthAccessTokenResolver, + Resolver, + TokenType, +) +from models import Account +from models.oauth import OAuthAccessToken +from services.entities.feature_entities import ( + LicenseStatus, +) + +from ._world import ( + ACCOUNT_ID, + TOKEN_ID, + make_account, + make_auth, + persist, + system_features, +) + +ROUTER = "controllers.openapi.auth.router" +FEATURES = "controllers.openapi.auth.requirements.SystemFeatureService.get_public_system_features" +MOUNT = "controllers.openapi.auth.pipelines._mount_flask_login" +ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE}) + + +def never_reached(*_args: object, **_kwargs: object) -> NoReturn: + raise AssertionError("reached a step the router should have answered before") + + +def _authenticates(monkeypatch: pytest.MonkeyPatch, auth: AuthContext) -> None: + authenticator = SimpleNamespace(authenticate=lambda _token: auth) + monkeypatch.setattr(f"{ROUTER}.get_authenticator", lambda: authenticator) + + +def _guard( + view: Callable[..., object], + *, + requirements: tuple[Requirement, ...] = (), + edition: frozenset[DeploymentEdition] | None = None, +) -> Callable[..., object]: + return subject_router.guard(EndpointSpec(requirements=requirements, edition=edition))(view) + + +def _nothing(**_kwargs: object) -> None: + return None + + +def test_endpoint_edition_gate_404s_before_the_bearer_is_read( + app: Flask, + config_overrides: Callable[..., None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + monkeypatch.setattr(f"{ROUTER}.extract_bearer", never_reached) + view = _guard(_nothing, edition=ENTERPRISE_ONLY) + + with app.test_request_context("/openapi/v1/permitted-external-apps"): + with pytest.raises(NotFound): + view() + + +def test_a_dead_licence_403s_an_unauthenticated_caller_on_every_route_in_enterprise( + app: Flask, + config_overrides: Callable[..., None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The licence is a fact about the deployment, so the router checks it once, + for every route, before `extract_bearer` - a caller with no bearer at all + sees 403, not 401. + """ + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + monkeypatch.setattr(FEATURES, lambda: system_features(license_status=LicenseStatus.EXPIRED)) + monkeypatch.setattr(f"{ROUTER}.extract_bearer", never_reached) + view = _guard(_nothing) + + with app.test_request_context("/openapi/v1/account"): + with pytest.raises(Forbidden, match="license_invalid"): + view() + + +def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read( + app: Flask, + config_overrides: Callable[..., None], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Off means no authenticator is bound, so the router answers the same 503 + the device endpoints do instead of a 500 from the unbound authenticator. + """ + config_overrides(ENABLE_OAUTH_BEARER=False) + monkeypatch.setattr(f"{ROUTER}.extract_bearer", never_reached) + view = _guard(_nothing) + + with app.test_request_context("/openapi/v1/account"): + with pytest.raises(ServiceUnavailable, match="bearer_auth_disabled"): + view() + + +INVALID_BEARER = "invalid bearer" +"""The one answer the router gives a bearer it will not accept. + +`InvalidBearerError` is a plain `Exception`, so uncaught at this seam it reaches +`errorhandler(Exception)` and answers 500 — telling a caller whose token expired +that the server broke, when difyctl maps only 401/403 to re-authenticate. Every +rejection reason shares this one message: a caller that can tell them apart can +probe which tokens ever existed, the same reasoning as the 404-not-403 elsewhere +on this surface. +""" + + +_ResolverAndUpdates = tuple[Resolver, list[object]] +"""A resolver plus the row updates it issued while refusing.""" + + +def _resolver_never_asked() -> _ResolverAndUpdates: + """An unknown prefix is refused by the registry, before any resolver runs.""" + resolver = MagicMock() + resolver.resolve.side_effect = AssertionError("an unknown prefix must not reach a resolver") + return resolver, [] + + +def _resolver_with_no_live_row() -> _ResolverAndUpdates: + """`None` is what the shipped resolver answers for a token with no usable row — + never minted, revoked, or minted under the other variant's prefix. + """ + resolver = MagicMock() + resolver.resolve.return_value = None + return resolver, [] + + +class _FakeRedis: + def __init__(self) -> None: + self.store: dict[str, object] = {} + + def get(self, key: str) -> object | None: + return self.store.get(key) + + def setex(self, key: str, _ttl: int, value: object) -> None: + self.store[key] = value + + def delete(self, key: str) -> None: + self.store.pop(key, None) + + +class _OneRowSession: + """Enough `Session` for `_TokenTypeResolver` to read one row and expire it. + + SQLite hands `expires_at` back naive while the resolver compares it against + an aware `now`, so the expiry branch cannot be driven through the real + sqlite fixture — same reason `test_auth_matrix._MemoryResolver` exists. + """ + + def __init__(self, row: OAuthAccessToken) -> None: + self._row = row + self.updates: list[object] = [] + + def query(self, _model: object) -> _OneRowSession: + return self + + def filter(self, *_criteria: object) -> _OneRowSession: + return self + + def one_or_none(self) -> OAuthAccessToken: + return self._row + + def execute(self, statement: object) -> SimpleNamespace: + self.updates.append(statement) + return SimpleNamespace(rowcount=1) + + def commit(self) -> None: + return None + + +def _expired_row() -> OAuthAccessToken: + """Live in every respect but its expiry, so expiry is the only refusal.""" + row = OAuthAccessToken( + subject_email="account@example.com", + client_id="openapi-client", + device_label="laptop", + prefix=TokenType.OAUTH_ACCOUNT.prefix, + expires_at=datetime.now(UTC) - timedelta(seconds=1), + subject_issuer="dify:account", + account_id=ACCOUNT_ID, + token_hash="deadbeef", + ) + row.id = TOKEN_ID + return row + + +def _resolver_for_an_expired_row() -> _ResolverAndUpdates: + """The shipped resolver, over a row whose expiry has passed.""" + session = _OneRowSession(_expired_row()) + resolver = OAuthAccessTokenResolver(lambda: session, _FakeRedis()) + return resolver.for_token_type(TokenType.OAUTH_ACCOUNT), session.updates + + +def _authenticates_for_real( + monkeypatch: pytest.MonkeyPatch, resolver: Resolver, *, token_type: TokenType = TokenType.OAUTH_ACCOUNT +) -> None: + """The real `BearerAuthenticator`, so the refusals are its own, not a stub's.""" + monkeypatch.setattr( + rate_limit_module, + "LIMIT_BEARER_PER_TOKEN", + rate_limit_module.RateLimit( + 0, + rate_limit_module.LIMIT_BEARER_PER_TOKEN.window, + rate_limit_module.LIMIT_BEARER_PER_TOKEN.scopes, + ), + ) + authenticator = BearerAuthenticator({token_type: resolver}) + monkeypatch.setattr(f"{ROUTER}.get_authenticator", lambda: authenticator) + + +def _refuse(app: Flask, token: str) -> Unauthorized: + view = _guard(_nothing) + with app.test_request_context("/openapi/v1/account", headers={"Authorization": f"Bearer {token}"}): + with pytest.raises(Unauthorized) as raised: + view() + return raised.value + + +@pytest.mark.parametrize( + ("resolver_factory", "token", "rows_expired"), + [ + (_resolver_never_asked, "zzz_notatokenkind", 0), + (_resolver_with_no_live_row, f"{TokenType.OAUTH_ACCOUNT.prefix}revoked", 0), + (_resolver_for_an_expired_row, f"{TokenType.OAUTH_ACCOUNT.prefix}stale", 1), + ], + ids=["unknown prefix", "no live row", "expired"], +) +def test_every_way_authenticate_rejects_a_bearer_answers_the_same_401( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + resolver_factory: Callable[[], _ResolverAndUpdates], + token: str, + rows_expired: int, +) -> None: + """`rows_expired` pins that the expired row really took the expiry branch, + rather than reaching the same answer as a token that was never minted. + """ + resolver, updates = resolver_factory() + _authenticates_for_real(monkeypatch, resolver) + + refusal = _refuse(app, token) + + assert (refusal.code, refusal.description) == (401, INVALID_BEARER) + assert len(updates) == rows_expired + + +def test_an_unresolvable_external_bearer_on_community_is_refused_as_a_bearer_not_an_edition( + app: Flask, config_overrides: Callable[..., None], monkeypatch: pytest.MonkeyPatch +) -> None: + """`_RequiresEnterprise` runs after `authenticate`. A `dfoe_` string with no + live row must hear the same 401 as any bad bearer; a 403 + `external_sso_requires_ee` first would let an unauthenticated caller + learn the deployment edition. + """ + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + resolver, _ = _resolver_with_no_live_row() + _authenticates_for_real(monkeypatch, resolver, token_type=TokenType.OAUTH_EXTERNAL_SSO) + + refusal = _refuse(app, f"{TokenType.OAUTH_EXTERNAL_SSO.prefix}garbage") + + assert (refusal.code, refusal.description) == (401, INVALID_BEARER) + + +def test_an_account_token_reaches_the_view_with_a_resolved_context( + app: Flask, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + persist(sqlite_session, make_account()) + _authenticates(monkeypatch, make_auth(TokenType.OAUTH_ACCOUNT)) + mounted: list[object] = [] + monkeypatch.setattr(MOUNT, mounted.append) + seen: dict[str, object] = {} + + def handler(resource: str, *, ctx: Context) -> str: + seen.update(resource=resource, ctx=ctx) + return "answered" + + view = _guard(handler) + + with app.test_request_context("/openapi/v1/account", headers={"Authorization": "Bearer tok"}): + result = view("self") + + assert result == "answered" + assert isinstance(seen["ctx"], Context) + assert isinstance(seen["ctx"].subject, AccountSubject) + assert seen["resource"] == "self" + assert [type(user) for user in mounted] == [Account] + + +def _rename_handler(*, ctx: Context) -> str: + ctx.caller.name = "renamed" + return "ok" + + +def test_a_mutation_is_committed_when_the_view_returns( + app: Flask, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +) -> None: + persist(sqlite_session, make_account()) + _authenticates(monkeypatch, make_auth(TokenType.OAUTH_ACCOUNT)) + monkeypatch.setattr(MOUNT, lambda _user: None) + view = _guard(_rename_handler) + + with app.test_request_context("/openapi/v1/account", headers={"Authorization": "Bearer tok"}): + view() + + with sqlite_session_factory() as verify: + renamed = verify.get(Account, ACCOUNT_ID) + assert renamed is not None + assert renamed.name == "renamed" + + +def _rename_then_fail(*, ctx: Context) -> str: + _rename_handler(ctx=ctx) + raise RuntimeError("after the write") + + +def test_a_mutation_is_rolled_back_when_the_view_raises( + app: Flask, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +) -> None: + persist(sqlite_session, make_account()) + _authenticates(monkeypatch, make_auth(TokenType.OAUTH_ACCOUNT)) + monkeypatch.setattr(MOUNT, lambda _user: None) + view = _guard(_rename_then_fail) + + with app.test_request_context("/openapi/v1/account", headers={"Authorization": "Bearer tok"}): + with pytest.raises(RuntimeError, match="after the write"): + view() + + with sqlite_session_factory() as verify: + unchanged = verify.get(Account, ACCOUNT_ID) + assert unchanged is not None + assert unchanged.name == "OpenAPI account" diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_subjects.py b/api/tests/unit_tests/controllers/openapi/auth/test_subjects.py new file mode 100644 index 00000000000..ce73594e2cc --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_subjects.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from sqlalchemy.orm import Session +from werkzeug.exceptions import Unauthorized + +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject +from libs.oauth_bearer import TokenType +from models import Account, EndUser, TenantAccountJoin +from models.account import TenantAccountRole +from models.enums import EndUserType + +from ._world import ( + ACCOUNT_ID, + APP_ID, + SSO_EMAIL, + TENANT_ID, + make_account, + make_app, + make_auth, + make_ctx, + make_tenant, + persist, +) + + +@pytest.mark.parametrize(("has_app", "expected"), [(False, False), (True, True)], ids=["app-less", "app route"]) +def test_external_sso_mounts_a_caller_only_on_app_routes( + has_app: bool, expected: bool, sqlite_session: Session +) -> None: + subject = ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO)) + view_args: dict[str, str] = {"app_id": APP_ID} if has_app else {} + + assert subject.mounts_caller(make_ctx(sqlite_session, subject, **view_args)) is expected + + +class TestAccountResolveCaller: + def test_binds_the_current_tenant_to_the_workspace_the_route_resolved(self, sqlite_session: Session) -> None: + """A loaded workspace is the whole signal. `app_id` in the path says one + *can* be resolved, never that anything did — and binding on that would + read a workspace no requirement asked for. + """ + account = make_account() + tenant = make_tenant() + persist( + sqlite_session, + account, + tenant, + TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + current=True, + role=TenantAccountRole.ADMIN, + ), + ) + subject = AccountSubject(make_auth(TokenType.OAUTH_ACCOUNT)) + ctx = make_ctx(sqlite_session, subject, app_id=APP_ID) + ctx._workspace = tenant + + caller = subject.resolve_caller(ctx, sqlite_session) + + assert isinstance(caller, Account) + assert caller.current_tenant_id == TENANT_ID + assert caller.role == TenantAccountRole.ADMIN + + def test_never_resolves_a_workspace_the_request_did_not_need(self, sqlite_session: Session) -> None: + persist(sqlite_session, make_account()) + subject = AccountSubject(make_auth(TokenType.OAUTH_ACCOUNT)) + + caller = subject.resolve_caller(make_ctx(sqlite_session, subject, app_id=APP_ID), sqlite_session) + + assert isinstance(caller, Account) + assert caller.current_tenant_id is None + + +class TestExternalSsoResolveCaller: + def test_resolves_the_end_user_against_the_apps_workspace(self, sqlite_session: Session) -> None: + """It loads both itself. Nothing before it on an SSO route needs a + workspace, so a subject that expected one to be there already would + resolve an end user against nothing. + """ + persist(sqlite_session, make_app(), make_tenant()) + subject = ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO)) + ctx = make_ctx(sqlite_session, subject, app_id=APP_ID) + end_user = EndUser( + tenant_id=TENANT_ID, + app_id=APP_ID, + type=EndUserType.OPENAPI, + is_anonymous=False, + session_id=SSO_EMAIL, + ) + + with patch( + "controllers.openapi.auth.subjects.EndUserService.get_or_create_end_user_by_type", + return_value=end_user, + ) as get_or_create: + assert subject.resolve_caller(ctx, sqlite_session) is end_user + + get_or_create.assert_called_once_with( + EndUserType.OPENAPI, + tenant_id=TENANT_ID, + app_id=APP_ID, + user_id=SSO_EMAIL, + ) + + def test_rejects_a_token_without_an_external_identity(self, sqlite_session: Session) -> None: + subject = ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO, subject_email=None)) + ctx = make_ctx(sqlite_session, subject, app_id=APP_ID) + + with pytest.raises(Unauthorized, match="missing context for external user resolution"): + subject.resolve_caller(ctx, sqlite_session) + + +class TestExternalSsoWebappUserId: + def test_resolves_the_account_behind_the_sso_email(self, sqlite_session: Session) -> None: + persist(sqlite_session, make_account(email=SSO_EMAIL)) + subject = ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO)) + + assert subject.webapp_user_id(sqlite_session) == ACCOUNT_ID + + def test_refuses_to_guess_when_the_email_matches_no_account(self, sqlite_session: Session) -> None: + assert ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO)).webapp_user_id(sqlite_session) is None + + identityless = ExternalSsoSubject(make_auth(TokenType.OAUTH_EXTERNAL_SSO, subject_email=None)) + assert identityless.webapp_user_id(sqlite_session) is None diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_verify.py b/api/tests/unit_tests/controllers/openapi/auth/test_verify.py deleted file mode 100644 index baad1264abf..00000000000 --- a/api/tests/unit_tests/controllers/openapi/auth/test_verify.py +++ /dev/null @@ -1,268 +0,0 @@ -import uuid -from collections.abc import Callable -from unittest.mock import patch - -import pytest -from flask import Flask -from werkzeug.exceptions import Forbidden, NotFound - -from controllers.common.rbac import PlainApp, RBACCheck -from controllers.openapi.auth.data import AuthData -from controllers.openapi.auth.verify import ( - check_acl, - check_app_api_enabled, - check_private_app_permission, - check_rbac_permission, - check_scope, - check_workspace_member, - check_workspace_mismatch, - check_workspace_role, -) -from core.rbac import RBACPermission -from libs.oauth_bearer import Scope, TokenType -from models.account import Tenant, TenantAccountRole -from models.model import App -from services.enterprise.enterprise_service import WebAppAccessMode - - -@pytest.fixture(autouse=True) -def _rbac_config(config_overrides: Callable[..., None]) -> None: - config_overrides(RBAC_ENABLED=True) - - -def _data(**kwargs) -> AuthData: - defaults: dict = {"token_type": TokenType.OAUTH_ACCOUNT, "scopes": frozenset({Scope.FULL})} - defaults.update(kwargs) - return AuthData(**defaults) - - -def test_check_scope_passes_when_required_is_none(): - check_scope(_data(required_scope=None)) - - -def test_check_scope_passes_when_full_in_scopes(): - check_scope(_data(required_scope=Scope.APPS_RUN, scopes=frozenset({Scope.FULL}))) - - -def test_check_scope_passes_when_exact_scope_present(): - check_scope(_data(required_scope=Scope.APPS_RUN, scopes=frozenset({Scope.APPS_RUN}))) - - -def test_check_scope_raises_forbidden_when_scope_missing(): - with pytest.raises(Forbidden, match="insufficient_scope"): - check_scope(_data(required_scope=Scope.APPS_RUN, scopes=frozenset({Scope.APPS_READ}))) - - -def test_check_workspace_member_raises_not_found_when_no_role(): - with pytest.raises(NotFound, match="workspace not found"): - check_workspace_member(_data(tenant_role=None)) - - -def test_check_workspace_member_passes_when_role_present(): - check_workspace_member(_data(tenant_role=TenantAccountRole.NORMAL)) - - -# --- check_rbac_permission --- - -_RBAC_REQ = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()) - - -def test_check_rbac_noop_when_no_requirement(): - with patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce: - check_rbac_permission(_data(rbac=None, caller_kind="account")) - mock_enforce.assert_not_called() - - -def test_check_rbac_noop_when_rbac_disabled(config_overrides: Callable[..., None]): - config_overrides(RBAC_ENABLED=False) - with patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce: - check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account")) - mock_enforce.assert_not_called() - - -def test_check_rbac_skips_end_user_caller(): - with ( - patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce, - ): - check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="end_user")) - mock_enforce.assert_not_called() - - -def test_check_rbac_raises_when_context_missing(): - with pytest.raises(Forbidden, match="rbac context missing"): - check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account", account_id=None, tenant=None)) - - -def test_check_rbac_enforces_for_account_caller(): - tenant = Tenant(name="Test Tenant") - tenant.id = "t1" - account_id = uuid.uuid4() - data = _data( - rbac=_RBAC_REQ, - caller_kind="account", - account_id=account_id, - tenant=tenant, - path_params={"app_id": "app-1"}, - ) - with ( - patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce, - ): - check_rbac_permission(data) - mock_enforce.assert_called_once() - call_kwargs = mock_enforce.call_args.kwargs - assert call_kwargs["tenant_id"] == "t1" - assert call_kwargs["account_id"] == str(account_id) - assert call_kwargs["path_args"] == {"app_id": "app-1"} - (check,) = call_kwargs["checks"] - assert isinstance(check, RBACCheck) - assert check.scene is RBACPermission.APP_VIEW_LAYOUT - assert isinstance(check.locator, PlainApp) - - -def test_check_acl_raises_when_app_or_mode_missing(): - with pytest.raises(Forbidden): - check_acl(_data(app=None, app_access_mode=None)) - - -def test_check_acl_account_allowed_for_public(): - app = App() - data = _data(token_type=TokenType.OAUTH_ACCOUNT, app=app, app_access_mode=WebAppAccessMode.PUBLIC) - check_acl(data) - - -def test_check_acl_external_sso_blocked_for_private(): - app = App() - data = _data( - token_type=TokenType.OAUTH_EXTERNAL_SSO, - app=app, - app_access_mode=WebAppAccessMode.PRIVATE, - ) - with pytest.raises(Forbidden, match="subject_not_allowed_for_access_mode"): - check_acl(data) - - -def test_check_acl_external_sso_allowed_for_sso_verified(): - app = App() - data = _data( - token_type=TokenType.OAUTH_EXTERNAL_SSO, - app=app, - app_access_mode=WebAppAccessMode.SSO_VERIFIED, - ) - check_acl(data) - - -def test_check_private_app_permission_raises_when_app_none(): - with pytest.raises(Forbidden): - check_private_app_permission(_data(app=None)) - - -def test_check_private_app_permission_raises_when_user_not_allowed(): - app = App( - id="app-1", - ) - data = _data(account_id=uuid.uuid4(), app=app) - target = "controllers.openapi.auth.verify.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp" - with patch(target, return_value=False): - with pytest.raises(Forbidden, match="user_not_allowed_for_private_app"): - check_private_app_permission(data) - - -def test_check_private_app_permission_passes_when_allowed(): - app = App( - id="app-1", - ) - data = _data(account_id=uuid.uuid4(), app=app) - target = "controllers.openapi.auth.verify.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp" - with patch(target, return_value=True): - check_private_app_permission(data) - - -# --- check_workspace_mismatch --- - - -@pytest.fixture -def flask_app(): - return Flask(__name__) - - -def test_check_workspace_mismatch_passes_when_tenant_none(flask_app): - with flask_app.test_request_context("/test"): - check_workspace_mismatch(_data(tenant=None)) - - -def test_check_workspace_mismatch_passes_when_ids_match(flask_app): - tenant = Tenant(name="Test Tenant") - tid = uuid.uuid4() - tenant.id = tid - with flask_app.test_request_context(f"/test?workspace_id={tid}"): - check_workspace_mismatch(_data(tenant=tenant, path_params={})) - - -def test_check_workspace_mismatch_raises_422_on_mismatch(flask_app): - from werkzeug.exceptions import UnprocessableEntity - - tenant = Tenant(name="Test Tenant") - tenant.id = uuid.uuid4() - other_id = uuid.uuid4() - with flask_app.test_request_context(f"/test?workspace_id={other_id}"): - with pytest.raises(UnprocessableEntity): - check_workspace_mismatch(_data(tenant=tenant, path_params={})) - - -def test_check_workspace_mismatch_passes_when_no_request_workspace_id(flask_app): - tenant = Tenant(name="Test Tenant") - tenant.id = uuid.uuid4() - with flask_app.test_request_context("/test"): - check_workspace_mismatch(_data(tenant=tenant, path_params={})) - - -# --- check_workspace_role --- - - -def test_check_workspace_role_passes_when_allowed_roles_none(): - check_workspace_role(_data(allowed_roles=None)) - - -def test_check_workspace_role_raises_not_found_when_not_member(): - data = _data(tenant_role=None, allowed_roles=frozenset({TenantAccountRole.ADMIN})) - with pytest.raises(NotFound): - check_workspace_role(data) - - -def test_check_workspace_role_raises_forbidden_when_wrong_role(): - data = _data( - tenant_role=TenantAccountRole.EDITOR, - allowed_roles=frozenset({TenantAccountRole.OWNER}), - ) - with pytest.raises(Forbidden, match="insufficient workspace role"): - check_workspace_role(data) - - -def test_check_workspace_role_passes_when_role_allowed(): - data = _data( - tenant_role=TenantAccountRole.ADMIN, - allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}), - ) - check_workspace_role(data) - - -# --- check_app_api_enabled --- - - -def test_check_app_api_enabled_passes_when_enabled(): - app = App( - enable_api=True, - ) - check_app_api_enabled(_data(app=app)) - - -def test_check_app_api_enabled_raises_forbidden_when_disabled(): - app = App( - enable_api=False, - ) - with pytest.raises(Forbidden, match="service_api_disabled"): - check_app_api_enabled(_data(app=app)) - - -def test_check_app_api_enabled_passes_when_app_none(): - check_app_api_enabled(_data(app=None)) diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py index f77e35a3b7f..5368d88c70f 100644 --- a/api/tests/unit_tests/controllers/openapi/conftest.py +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -1,51 +1,23 @@ +from __future__ import annotations + import uuid +from collections.abc import Callable +from dataclasses import dataclass +from types import SimpleNamespace import pytest from flask import Flask +from flask.testing import FlaskClient +from sqlalchemy.orm import Session from app_factory import create_flask_app_with_configs from controllers.openapi import bp as openapi_bp -from controllers.openapi.auth.data import AuthData -from controllers.openapi.auth.pipeline import PipelineRouter -from libs.oauth_bearer import Scope, TokenType - - -def _stub_execute( - self, - args, - kwargs, - view, - *, - scope=None, - allowed_token_types=None, - edition=None, - require_valid_enterprise_license=False, - workspace_membership=False, - allowed_roles=None, - rbac=None, -): - """Bypass all auth logic; inject minimal AuthData and call the view directly.""" - kwargs["auth_data"] = AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=uuid.uuid4(), - token_id=uuid.uuid4(), - scopes=frozenset({Scope.FULL}), - required_scope=scope, - allowed_roles=allowed_roles, - rbac=rbac, - ) - return view(*args, **kwargs) - - -@pytest.fixture -def bypass_pipeline(monkeypatch: pytest.MonkeyPatch): - """Stub PipelineRouter._execute so endpoints skip real auth at request time. - - Module-level @auth_router.guard(...) captures the real router at import - time — patching guard itself does nothing. Patching _execute on the class - is the seam that fires at request time. - """ - monkeypatch.setattr(PipelineRouter, "_execute", _stub_execute) +from enums import DeploymentEdition +from libs.oauth_bearer import AuthContext, TokenType +from models import Account, App, Tenant, TenantAccountJoin +from models.account import AccountStatus, TenantAccountRole, TenantStatus +from models.enums import AppStatus +from models.model import AppMode, IconType @pytest.fixture @@ -62,3 +34,89 @@ def app(): a = Flask(__name__) a.config["TESTING"] = True return a + + +@dataclass(frozen=True, slots=True) +class AdmittedWorld: + """The ids a request needs to address the world `admitted_bearer` built.""" + + client: FlaskClient + workspace_id: str + app_id: str + member_id: str + headers: dict[str, str] + + +@pytest.fixture +def admitted_bearer( + openapi_app: Flask, + sqlite_session: Session, + config_overrides: Callable[..., None], + monkeypatch: pytest.MonkeyPatch, +) -> AdmittedWorld: + """A caller every guarded route admits, so a request reaches `@accepts`. + + `@endpoint` stacks `@accepts` *inside* the guard, so a 422 is only observable + on the wire once auth has passed. Everything here is world — one owner account + in one workspace owning one API-enabled app — plus the two process seams the + router already treats as pluggable: the bound authenticator, and the + flask-login mount, which needs a real Flask login manager it has no reason to + carry here. + """ + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, RBAC_ENABLED=False) + + account_id, workspace_id, app_id = (str(uuid.uuid4()) for _ in range(3)) + account = Account(name="caller", email="caller@example.com", status=AccountStatus.ACTIVE) + account.id = account_id + workspace = Tenant(name="caller workspace", status=TenantStatus.NORMAL) + workspace.id = workspace_id + sqlite_session.add_all( + [ + account, + workspace, + TenantAccountJoin( + tenant_id=workspace_id, + account_id=account_id, + current=True, + role=TenantAccountRole.OWNER, + ), + App( + id=app_id, + tenant_id=workspace_id, + name="caller app", + description="", + mode=AppMode.WORKFLOW, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#FFFFFF", + status=AppStatus.NORMAL, + enable_site=True, + enable_api=True, + max_active_requests=None, + ), + ] + ) + sqlite_session.commit() + + token = AuthContext( + subject_email=account.email, + subject_issuer="dify:account", + account_id=uuid.UUID(account_id), + client_id="difyctl", + token_id=uuid.uuid4(), + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, + ) + monkeypatch.setattr( + "controllers.openapi.auth.router.get_authenticator", + lambda: SimpleNamespace(authenticate=lambda _token: token), + ) + monkeypatch.setattr("controllers.openapi.auth.pipelines._mount_flask_login", lambda _user: None) + + return AdmittedWorld( + client=openapi_app.test_client(), + workspace_id=workspace_id, + app_id=app_id, + member_id=str(uuid.uuid4()), + headers={"Authorization": "Bearer dfoa_admitted"}, + ) diff --git a/api/tests/unit_tests/controllers/openapi/test_account.py b/api/tests/unit_tests/controllers/openapi/test_account.py index a623d29ca69..13f86049a3c 100644 --- a/api/tests/unit_tests/controllers/openapi/test_account.py +++ b/api/tests/unit_tests/controllers/openapi/test_account.py @@ -2,119 +2,58 @@ import builtins import sys +import uuid from types import SimpleNamespace import pytest from flask import Flask from flask.views import MethodView -from werkzeug.exceptions import NotFound, UnprocessableEntity +from pydantic import ValidationError +from werkzeug.exceptions import NotFound -from controllers.openapi import bp as openapi_bp -from controllers.openapi.account import ( - AccountApi, - AccountSessionByIdApi, - AccountSessionsApi, - AccountSessionsSelfApi, -) +from controllers.openapi._models import SessionListQuery +from controllers.openapi.account import AccountSessionByIdApi, AccountSessionsApi from machinery.context import AccountRequestContext +from services.account_errors import AccountSessionNotFoundError from services.entities.account_access_entities import AccountSessionPage if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] -@pytest.fixture -def openapi_app() -> Flask: - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(openapi_bp) - return app - - -def _rule(app: Flask, path: str): - return next(r for r in app.url_map.iter_rules() if r.rule == path) - - -def test_account_route_registered(openapi_app: Flask): - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/account" in rules - - -def test_account_dispatches_to_class(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/account") - assert openapi_app.view_functions[rule.endpoint].view_class is AccountApi - - -def test_account_sessions_self_route_registered(openapi_app: Flask): - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/account/sessions/self" in rules - - -def test_sessions_self_dispatches_to_class(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/account/sessions/self") - assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionsSelfApi - - -def test_account_methods(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/account") - assert "GET" in rule.methods - - -def test_sessions_self_methods(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/account/sessions/self") - assert "DELETE" in rule.methods - - -def test_sessions_list_route_registered(openapi_app: Flask): - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/account/sessions" in rules - - -def test_sessions_list_dispatches_to_sessions_api(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/account/sessions") - assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionsApi - assert "GET" in rule.methods - - -def test_session_by_id_route_registered(openapi_app: Flask): - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/account/sessions/" in rules - - -def test_session_by_id_dispatches_to_correct_class(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/account/sessions/") - assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionByIdApi - assert "DELETE" in rule.methods - - -def test_session_by_id_rejects_malformed_uuid(app: Flask) -> None: +@pytest.mark.parametrize("session_id", [str(uuid.uuid4()), "not-a-uuid"], ids=["foreign", "malformed"]) +def test_revoke_by_id_hides_a_session_the_caller_does_not_own( + app: Flask, monkeypatch: pytest.MonkeyPatch, session_id: str +) -> None: + """The access service refuses a token id owned by another account, and a + malformed id never reaches it; the route answers 404 either way, not 403, + so session ids cannot be probed across accounts. + """ api = AccountSessionByIdApi() - with app.test_request_context("/openapi/v1/account/sessions/not-a-uuid", method="DELETE"): + _stub_account_service(monkeypatch) + with app.test_request_context(f"/openapi/v1/account/sessions/{session_id}", method="DELETE"): with pytest.raises(NotFound, match="session not found"): - api.delete.__wrapped__(api, _request_context(), session_id="not-a-uuid") + api.delete.__handler__(api, _ctx(), session_id=session_id) -# --- GET /account/sessions query validation (the handler routes ?page/?limit through -# SessionListQuery so the server enforces the bounds the contract advertises). The application -# service is replaced with a small fake so these exercise only parsing and serialization; -# __wrapped__ skips the complete Admission boundary. --- +# --- GET /account/sessions query validation. The application service is replaced +# with a small fake so these exercise only the handler's projection; `__handler__` +# receives an already-validated query, so the bounds are pinned at the model. --- _ACCOUNT_MOD = "controllers.openapi.account" -def _request_context() -> AccountRequestContext: - return AccountRequestContext( - request_id="request-1", - trace_id="trace-1", - account_id="account-1", - access_token_id="token-1", - ) +def _ctx() -> SimpleNamespace: + return SimpleNamespace(subject=SimpleNamespace(account_id=uuid.uuid4(), token_id=uuid.uuid4())) class _SessionListService: def list_sessions(self, _context: AccountRequestContext, *, page: int, limit: int) -> AccountSessionPage: return AccountSessionPage(page=page, limit=limit, total=0, items=()) + def revoke_session(self, _context: AccountRequestContext, *, token_id: str) -> None: + raise AccountSessionNotFoundError(token_id) + def _stub_account_service(monkeypatch: pytest.MonkeyPatch) -> None: mod = sys.modules[_ACCOUNT_MOD] @@ -123,44 +62,26 @@ def _stub_account_service(monkeypatch: pytest.MonkeyPatch) -> None: def test_sessions_list_valid_query_parses_page_and_limit(app: Flask, monkeypatch: pytest.MonkeyPatch): - """A valid ?page&limit round-trips through SessionListQuery into the response envelope.""" + """A valid page/limit round-trips through SessionListQuery into the response envelope.""" api = AccountSessionsApi() _stub_account_service(monkeypatch) with app.test_request_context("/openapi/v1/account/sessions?page=2&limit=5"): - body, status = api.get.__wrapped__(api, _request_context()) - assert status == 200 - assert body["page"] == 2 - assert body["limit"] == 5 - assert body["total"] == 0 - assert body["data"] == [] - - -def test_sessions_list_defaults_when_query_omitted(app: Flask, monkeypatch: pytest.MonkeyPatch): - """No query → the model's defaults (page=1, limit=100) drive the envelope.""" - api = AccountSessionsApi() - _stub_account_service(monkeypatch) - with app.test_request_context("/openapi/v1/account/sessions"): - body, status = api.get.__wrapped__(api, _request_context()) - assert status == 200 - assert body["page"] == 1 - assert body["limit"] == 100 + result = api.get.__handler__(api, _ctx(), query=SessionListQuery(page=2, limit=5)) + assert result.page == 2 + assert result.limit == 5 + assert result.total == 0 + assert result.data == [] @pytest.mark.parametrize( - "query", + "params", [ - "page=0", # below ge=1 (previously coerced to a silent empty slice) - "page=-3", - "limit=0", # below ge=1 - "limit=999", # above le=MAX_PAGE_LIMIT - "page=abc", # not an integer (previously a 500) - "foo=bar", # extra='forbid' + {"page": "0"}, + {"limit": "0"}, + {"limit": "999"}, + {"foo": "bar"}, ], ) -def test_sessions_list_rejects_out_of_bounds_query(app: Flask, monkeypatch: pytest.MonkeyPatch, query): - """Out-of-range / unknown query params raise 422 instead of being silently coerced.""" - api = AccountSessionsApi() - _stub_account_service(monkeypatch) - with app.test_request_context(f"/openapi/v1/account/sessions?{query}"): - with pytest.raises(UnprocessableEntity): - api.get.__wrapped__(api, _request_context()) +def test_session_list_query_rejects_out_of_bounds(params): + with pytest.raises(ValidationError): + SessionListQuery.model_validate(params) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py index 25b0cd85917..6b75a179a35 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py @@ -1,5 +1,6 @@ -from inspect import unwrap +from collections.abc import Callable from types import SimpleNamespace +from typing import Protocol, cast from unittest.mock import Mock import pytest @@ -10,9 +11,18 @@ from werkzeug.exceptions import Forbidden from controllers.openapi import app_dsl as app_dsl_module from controllers.openapi._models import AppDslImportPayload from controllers.openapi.app_dsl import AppDslImportApi, AppDslImportConfirmApi +from controllers.openapi.auth.spec import EndpointSpec +from models import Account from services.errors.account import NoPermissionError +class _EndpointView(Protocol): + """Structural stand-in for a `view` carrying the attributes `@endpoint` attaches.""" + + __spec__: EndpointSpec + __handler__: Callable[..., object] + + @pytest.mark.parametrize( ("api", "kwargs"), [ @@ -44,6 +54,6 @@ def test_permission_denial_maps_to_forbidden( with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): with pytest.raises(Forbidden, match="denied") as exc_info: - unwrap(api.post)(api, auth_data=SimpleNamespace(caller=Mock()), **kwargs) + cast(_EndpointView, api.post).__handler__(api, SimpleNamespace(account=Mock(spec=Account)), **kwargs) assert isinstance(exc_info.value.__cause__, NoPermissionError) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py index 2f548714484..6ce3476808f 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py @@ -9,16 +9,15 @@ from sqlalchemy.orm import Session from controllers.openapi.apps import ( # pyright: ignore[reportPrivateUsage] _EMPTY_PARAMETERS, - _is_listable, parameters_payload, ) from controllers.service_api.app.error import AppUnavailableError -from models.model import App, AppMode +from models.model import App from tests.unit_tests.model_factories import make_app -def _app(*, mode: AppMode = AppMode.CHAT) -> App: - return make_app(app_id="app1", name="X", description="d", mode=mode, enable_site=False, max_active_requests=0) +def _app() -> App: + return make_app(app_id="app1", name="X", description="d", enable_site=False, max_active_requests=0) def test_parameters_payload_raises_app_unavailable_when_no_config(unbound_session: Session): @@ -44,16 +43,3 @@ def test_empty_parameters_constant_matches_describe_fallback_shape(): assert _EMPTY_PARAMETERS["opening_statement"] is None assert _EMPTY_PARAMETERS["file_upload"] is None assert _EMPTY_PARAMETERS["system_parameters"] == {} - - -@pytest.mark.parametrize( - "mode", - [AppMode.COMPLETION, AppMode.CHAT, AppMode.ADVANCED_CHAT, AppMode.WORKFLOW, AppMode.AGENT_CHAT], -) -def test_is_listable_accepts_supported_app_types(mode): - assert _is_listable(_app(mode=mode)) is True - - -@pytest.mark.parametrize("mode", [AppMode.AGENT, AppMode.CHANNEL, AppMode.RAG_PIPELINE]) -def test_is_listable_hides_non_app_modes(mode): - assert _is_listable(_app(mode=mode)) is False diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index 88af3d3ff8f..fba3e9d91f6 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -4,13 +4,16 @@ from __future__ import annotations import sys import uuid +from types import SimpleNamespace from unittest.mock import Mock import pytest from flask import Flask -from controllers.openapi._models import AppRunRequest +from controllers.openapi._models import AppRunRequest, TaskStopResponse +from controllers.openapi.app_run import AppRunApi, AppRunTaskStopApi from models import Account +from models.enums import CreatorUserRole from models.model import App, AppMode _TEST_APP_ID = str(uuid.uuid4()) @@ -36,66 +39,13 @@ def _make_account() -> Account: def test_app_run_request_has_no_response_mode_field(): - """response_mode must not be a declared field.""" + """Not a declared field, and a body carrying it is accepted and ignored.""" assert "response_mode" not in AppRunRequest.model_fields - - -def test_app_run_request_ignores_response_mode_in_payload(): - """Sending response_mode in JSON body is silently ignored (Pydantic extra='ignore').""" req = AppRunRequest.model_validate({"inputs": {}, "response_mode": "blocking"}) assert not hasattr(req, "response_mode") -def test_app_run_request_valid_minimal(): - req = AppRunRequest.model_validate({"inputs": {}}) - assert req.inputs == {} - - -def test_app_run_request_with_query(): - req = AppRunRequest.model_validate({"inputs": {}, "query": "hello"}) - assert req.query == "hello" - - -def test_run_chat_always_calls_generate_with_streaming_true( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch -): - """_run_chat must always invoke AppGenerateService.generate with streaming=True.""" - from controllers.openapi.app_run import _run_chat - - generate_mock = Mock(return_value=iter([])) - - class GenerateService: - generate = generate_mock - - monkeypatch.setattr( - sys.modules["controllers.openapi.app_run"], - "AppGenerateService", - GenerateService, - ) - with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"): - _run_chat( - _make_app(), - _make_account(), - AppRunRequest(inputs={}, query="hello"), - Mock(), - ) - _, kwargs = generate_mock.call_args - assert kwargs["streaming"] is True - - -def test_stop_task_endpoint_registered(openapi_app): - """POST /openapi/v1/apps//tasks/:stop must be registered.""" - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/apps//tasks/:stop" in rules - - -def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - import uuid - - from controllers.openapi.app_run import AppRunTaskStopApi - from controllers.openapi.auth.data import AuthData - from libs.oauth_bearer import Scope, TokenType - +def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch: pytest.MonkeyPatch): queue_mock = Mock() graph_mock = Mock() graph_instance = Mock() @@ -106,24 +56,58 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipel monkeypatch.setattr(run_module, "GraphEngineManager", graph_mock) monkeypatch.setattr(run_module, "redis_client", object()) - auth_data = AuthData.model_construct( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=uuid.UUID(_TEST_ACCOUNT_ID), - scopes=frozenset({Scope.FULL}), - app=_make_app(), - caller=_make_account(), - caller_kind="account", - ) - api = AppRunTaskStopApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"): - result = api.post.__wrapped__( - api, - app_id="app-1", - task_id="task-1", - auth_data=auth_data, - ) + result = api.post.__handler__(api, _SealableContext(), app_id="app-1", task_id="task-1") queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1") graph_instance.send_stop_command.assert_called_once_with("task-1") - assert result == ({"result": "success"}, 200) + assert result == TaskStopResponse(result="success") + + +class _SealableContext: + """A `Context` stand-in that refuses reads once `seal()` is called. + + The router's session closes when the handler returns, so anything the SSE + body still needs off `ctx` would be read through a closed session. + """ + + def __init__(self, **values: object) -> None: + self._values = values + self._sealed = False + + def seal(self) -> None: + self._sealed = True + + def __getattr__(self, name: str) -> object: + if self._sealed: + raise AssertionError(f"ctx.{name} was read after the handler returned") + return self._values[name] + + +def test_run_reads_everything_off_the_context_before_streaming(app: Flask, monkeypatch: pytest.MonkeyPatch): + """The SSE body runs after the router's session is gone, and the generator + is always asked to stream. + """ + generate_mock = Mock(return_value=iter(["event: a\n\n", "event: b\n\n"])) + + class GenerateService: + generate = generate_mock + + monkeypatch.setattr(sys.modules["controllers.openapi.app_run"], "AppGenerateService", GenerateService) + + ctx = _SealableContext( + app=_make_app(), + caller=_make_account(), + session=Mock(), + subject=SimpleNamespace(caller_role=CreatorUserRole.ACCOUNT), + ) + + api = AppRunApi() + with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}:run", method="POST"): + response = api.post.__handler__(api, ctx, app_id=_TEST_APP_ID, body=AppRunRequest(inputs={}, query="hello")) + ctx.seal() + body = "".join(response.response) + + assert body == "event: a\n\nevent: b\n\n" + assert generate_mock.call_args.kwargs["streaming"] is True diff --git a/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py index b8d077e5df5..475333927c1 100644 --- a/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py +++ b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py @@ -1,14 +1,15 @@ -"""Unit tests for PermittedExternalAppsListQuery — the -/permitted-external-apps query validator. +"""Unit tests for the /permitted-external-apps routes. -Strict ConfigDict(extra='forbid'): cross-tenant tag/workspace_id are -unresolvable, so the model must reject them as 422 instead of silently -dropping them. Mode/name/page/limit have the same shape as AppListQuery. +`PermittedExternalAppsListQuery` is strict (`ConfigDict(extra='forbid')`): +cross-tenant tag/workspace_id are unresolvable, so the model must reject them as +422 instead of silently dropping them. Mode/name/page/limit have the same shape +as AppListQuery. + +The allow/deny answers live in `test_auth_matrix.py`. """ from __future__ import annotations -import inspect from types import SimpleNamespace from unittest.mock import patch @@ -22,8 +23,6 @@ from controllers.openapi.apps_permitted_external import ( ) from models.model import App, AppMode -from ._mode_constants import NON_LISTABLE_MODES - def test_query_defaults_match_apps_list(): q = PermittedExternalAppsListQuery.model_validate({}) @@ -41,40 +40,18 @@ def test_query_rejects_workspace_id(): PermittedExternalAppsListQuery.model_validate({"workspace_id": "ws-1"}) -def test_query_rejects_tag(): - """Tags are tenant-scoped; cross-tenant tag resolution is undefined.""" - with pytest.raises(ValidationError): - PermittedExternalAppsListQuery.model_validate({"tag": "prod"}) - - def test_query_validates_mode_against_supported_app_type(): with pytest.raises(ValidationError): PermittedExternalAppsListQuery.model_validate({"mode": "not-a-mode"}) -@pytest.mark.parametrize("mode", NON_LISTABLE_MODES) -def test_query_rejects_non_listable_app_modes(mode: str): - """Non-app runtime modes and roster-owned agent are not listable here.""" - with pytest.raises(ValidationError): - PermittedExternalAppsListQuery.model_validate({"mode": mode}) - - def test_query_clamps_limit_at_max(): with pytest.raises(ValidationError): PermittedExternalAppsListQuery.model_validate({"limit": 500}) -def test_query_accepts_valid_mode(): - """Pin the happy path: AppMode values pass.""" - q = PermittedExternalAppsListQuery.model_validate({"mode": "chat"}) - assert q.mode is not None - assert q.mode.value == "chat" - - def test_describe_forwards_request_session_to_response_builder(unbound_session: Session): api = PermittedExternalAppDescribeApi() - method = inspect.unwrap(api.get) - session = unbound_session app = App( id="app-id", tenant_id="tenant-1", @@ -83,7 +60,7 @@ def test_describe_forwards_request_session_to_response_builder(unbound_session: enable_site=True, enable_api=True, ) - auth_data = SimpleNamespace(app=app) + ctx = SimpleNamespace(app=app, session=unbound_session) query = SimpleNamespace(fields={"info"}) response = object() @@ -91,7 +68,7 @@ def test_describe_forwards_request_session_to_response_builder(unbound_session: "controllers.openapi.apps_permitted_external.build_app_describe_response", return_value=response, ) as build_response: - result = method(api, session, "app-id", auth_data=auth_data, query=query) + result = api.get.__handler__(api, ctx, "app-id", query=query) assert result is response - build_response.assert_called_once_with(app, query.fields, session=session) + build_response.assert_called_once_with(app, query.fields, session=unbound_session) diff --git a/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py new file mode 100644 index 00000000000..69c82d1e883 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_auth_matrix.py @@ -0,0 +1,1342 @@ +"""The /openapi/v1 allow/deny matrix, and a snapshot of the generated document. + +The matrix is derived from what actually runs, not from what a route declares: a +route's own requirements are merged with the fixed ones its subject's pipeline +carries, a `CheckWorkspaceRole` applies only where RBAC is off and a `CheckRBACPermission` only where it is on, +and `CheckAppAccess` applies the private-app check whether +or not `webapp_auth.enabled` gates the ACL. Reading declarations alone under-counts +every one of those. + +Requests run through the real Flask blueprint with the real router, the real +pipeline and real database rows. The only substitutions are at process seams the +router already treats as pluggable: the bound `BearerAuthenticator` (so a token's +subject kind and scope set are chosen per row), the enterprise HTTP clients, and +the flask-login mount. The RBAC backend and the enterprise web-app service are +stubbed per row, so those rows pin how *this* code handles each answer they can +give — never what the real services decide. + +Cases are never gated on whether a route declares a check. A row that asserts +`ADMIT` where RBAC, a workspace role check or a licence gate does not fire is pinning the +check's *absence*: losing a check is caught by the deny rows, and gaining one is +caught by these. `files.upload` is the route the design says must never acquire an +RBAC permission, and `files.upload-rbac_on_denied` is the row that says so. + +Admission is observed as HTTP 418: a `user_logged_in` receiver raises `ImATeapot` +at the moment the pipeline mounts the caller, which is after every requirement has +passed and before the view body runs. That keeps a row from depending on whether a +view could complete (several stream SSE or invoke a workflow). One route mounts no +caller — `permitted_external.list` resolves no end user because it carries no +`app_id` — so its admission row says so and asserts the view's own 200 instead. + +Two answers in the table are worth reading twice, because they are what the code +does rather than what a route's declaration suggests: + +* `workspaces.describe` declares no membership requirement, so a non-member is + *admitted* by auth and refused by the view's own lookup. `workspaces.switch`, + one path segment away, declares one and is refused by auth. +* No token the shipped registry mints can fail `CheckScope` — `dfoa_` carries + `Scope.FULL` and `dfoe_` carries exactly the two scopes its routes ask for — so + the scope rows mint from a deliberately narrowed registry. Those rows are not + dead weight and must not be simplified away: `CheckScope` is live code on every + request, and the day a narrower token kind is minted it is the only thing + standing between that token and every route it was not scoped for. + +`Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE` is the one case that combines a real +membership failure with a real scope failure on the same request. Neither +`INSUFFICIENT_SCOPE` nor `NON_MEMBER` alone proves which one a doubly-broken request +hears; membership runs EARLY and scope NORMAL, so it hears membership. + +Routes that declare the same requirement tuple answer every case the same way, +so `MATRIX` runs the cases on one representative per tuple and `DECLARED` pins +which tuple each of the 25 routes carries. A route that changes its tuple fails +the wiring test; a tuple that changes its answers fails its representative's rows. +""" + +from __future__ import annotations + +import contextvars +import uuid +from collections.abc import Callable, Iterator +from contextlib import ExitStack +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from enum import StrEnum, auto +from functools import singledispatch +from unittest.mock import patch + +import pytest +from flask import Flask +from flask_login import LoginManager, user_logged_in +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import ImATeapot +from werkzeug.routing import Rule +from werkzeug.test import TestResponse + +import libs.oauth_bearer as oauth_bearer_module +import libs.rate_limit as rate_limit_module +from app_factory import create_flask_app_with_configs +from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace +from controllers.openapi import bp as openapi_bp +from controllers.openapi.auth.requirements import ( + CheckAppAccess, + CheckAppApiEnabled, + CheckRBACPermission, + CheckScope, + CheckSubject, + CheckWorkspaceMember, + CheckWorkspaceRole, + Requirement, +) +from controllers.openapi.auth.spec import EndpointSpec +from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject +from controllers.openapi.human_input_form import CheckFormSurface +from enums import DeploymentEdition, WebAppAccessMode +from libs.oauth_bearer import ( + BearerAuthenticator, + ResolvedRow, + Scope, + TokenType, + sha256_hex, +) +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import EndUserType +from models.model import App, EndUser +from models.oauth import OAuthAccessToken +from services.account_service import AccountService +from services.end_user_service import EndUserService +from services.enterprise.enterprise_service import EnterpriseService +from services.entities.feature_entities import LicenseStatus, SystemFeatureModel +from services.rbac_resource_service import RBACResourceService +from services.system_feature_service import SystemFeatureService +from tests.unit_tests.config_override import apply_config_overrides + +ADMITTED = 418 + + +class Trait(StrEnum): + """Structural facts about a route, used only to decide which cases can reach it. + + Every member here is a fact about the *shape* of the request — is there an + `app_id` in the path, can a `dfoe_` token address this route at all. None is + read off a route's declared requirements, deliberately: gating a + case on a declared check means a route that *gains* that check during migration + simply loses the row, and gaining a check is the hazard this plan cares about + most. Cases like `RBAC_ON_DENIED` therefore run on every route an account + caller can reach, and assert `ADMIT` where the check does not exist today. + """ + + APP_SCOPED = auto() + ACCOUNT_PRIMARY = auto() + EXTERNAL_REACHABLE = auto() + ENTERPRISE_ONLY = auto() + + +class Case(StrEnum): + NO_BEARER = auto() + MEMBER = auto() + WRONG_SUBJECT = auto() + INSUFFICIENT_SCOPE = auto() + NON_MEMBER = auto() + NON_MEMBER_AND_INSUFFICIENT_SCOPE = auto() + LOW_ROLE = auto() + APP_API_DISABLED = auto() + UNKNOWN_APP = auto() + FOREIGN_WORKSPACE_QUERY = auto() + EDITION_NOT_ENTERPRISE = auto() + LICENSE_INVALID = auto() + EE_LICENSE_INVALID = auto() + EE_ACCOUNT_PUBLIC = auto() + EE_ACCOUNT_SSO_VERIFIED = auto() + EE_ACCOUNT_PRIVATE_ALL = auto() + EE_ACCOUNT_PRIVATE_PERMITTED = auto() + EE_ACCOUNT_PRIVATE_REFUSED = auto() + EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF = auto() + EE_ACCOUNT_MODE_UNRESOLVED = auto() + EE_EXTERNAL_PUBLIC = auto() + EE_EXTERNAL_SSO_VERIFIED = auto() + EE_EXTERNAL_PRIVATE_ALL = auto() + EE_EXTERNAL_PRIVATE = auto() + EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF = auto() + EE_EXTERNAL_MODE_UNRESOLVED = auto() + RBAC_ON_LOW_ROLE = auto() + RBAC_ON_DENIED = auto() + + +class Bearer(StrEnum): + NONE = auto() + ACCOUNT_MEMBER = auto() + ACCOUNT_LOW_ROLE = auto() + ACCOUNT_OUTSIDER = auto() + EXTERNAL = auto() + OTHER_SUBJECT = auto() + PRIMARY = auto() + + +@dataclass(frozen=True, slots=True) +class Route: + id: str + method: str + path: str + traits: frozenset[Trait] + query: str = "" + + +@dataclass(frozen=True, slots=True) +class Expect: + """`message` is the canonical ErrorBody message; `None` means it is not pinned.""" + + status: int + message: str | None = None + note: str = "" + + +@dataclass(frozen=True, slots=True) +class Scenario: + """The world a case runs in. `edition=None` means the route's native edition.""" + + bearer: Bearer + edition: DeploymentEdition | None = None + narrow_scopes: bool = False + app_api_enabled: bool = True + unknown_app: bool = False + foreign_workspace_query: bool = False + webapp_auth: bool = False + access_mode: WebAppAccessMode | None = WebAppAccessMode.PUBLIC + private_app_permitted: bool = True + license_status: LicenseStatus = LicenseStatus.ACTIVE + rbac_enabled: bool = False + rbac_allows: bool = True + + +ADMIT = Expect(ADMITTED) +DENY_NO_BEARER = Expect(401, "bearer required") +DENY_WRONG_SUBJECT = Expect(403, "unsupported_token_type") +DENY_SSO_NEEDS_EE = Expect(403, "external_sso_requires_ee") +DENY_SCOPE = Expect(403, "insufficient_scope") +DENY_NON_MEMBER = Expect(404, "workspace not found") +DENY_ROLE = Expect(403, "insufficient workspace role") +DENY_API_DISABLED = Expect(403, "service_api_disabled") +DENY_UNKNOWN_APP = Expect(404, "app not found") +DENY_ACCESS_MODE = Expect(403, "subject_not_allowed_for_access_mode") +DENY_MODE_UNRESOLVED = Expect(403, "app or access mode not loaded") +DENY_PRIVATE_APP = Expect(403, "user_not_allowed_for_private_app") +DENY_EDITION = Expect(404, note="endpoint-level edition gate, raised before the bearer is read") +DENY_LICENSE = Expect(403, "license_invalid") +DENY_RBAC = Expect(403, note="bare werkzeug Forbidden from enforce_rbac_checks") +ADMIT_NO_RBAC_PERMISSION = Expect( + ADMITTED, + note=( + "pins a check this route does NOT have: RBAC is on and denying, and the route still " + "admits because it declares no scene. Attaching one during migration turns this 403. " + "files.upload is the route the design says must never gain a scene" + ), +) +ADMIT_NO_WORKSPACE_ROLE = Expect( + ADMITTED, + note="pins a check this route does NOT have: RBAC is on and a NORMAL member is still admitted", +) +ADMIT_NO_LICENCE_GATE = Expect( + ADMITTED, + note="pins a check this route does NOT have: the licence is dead and the route still admits", +) +ADMIT_NO_MOUNT = Expect( + 200, + note="external subject mounts no caller on an app-less route, so admission shows as the view's own 200", +) + + +ROUTES: tuple[Route, ...] = ( + Route("account.get", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})), + Route("account.sessions.revoke_self", "DELETE", "/account/sessions/self", frozenset({Trait.ACCOUNT_PRIMARY})), + Route("account.sessions.list", "GET", "/account/sessions", frozenset({Trait.ACCOUNT_PRIMARY})), + Route( + "account.sessions.revoke_one", + "DELETE", + "/account/sessions/{session_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "apps.describe", + "GET", + "/apps/{app_id}", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route("apps.list", "GET", "/apps", frozenset({Trait.ACCOUNT_PRIMARY}), query="workspace_id={workspace_id}"), + Route("workspaces.list", "GET", "/workspaces", frozenset({Trait.ACCOUNT_PRIMARY})), + Route("workspaces.describe", "GET", "/workspaces/{workspace_id}", frozenset({Trait.ACCOUNT_PRIMARY})), + Route("workspaces.switch", "POST", "/workspaces/{workspace_id}:switch", frozenset({Trait.ACCOUNT_PRIMARY})), + Route("workspaces.members.list", "GET", "/workspaces/{workspace_id}/members", frozenset({Trait.ACCOUNT_PRIMARY})), + Route( + "workspaces.members.invite", + "POST", + "/workspaces/{workspace_id}/members", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "workspaces.members.remove", + "DELETE", + "/workspaces/{workspace_id}/members/{member_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "workspaces.members.update_role", + "PATCH", + "/workspaces/{workspace_id}/members/{member_id}", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "app_dsl.import", + "POST", + "/workspaces/{workspace_id}/apps/imports", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "app_dsl.import_confirm", + "POST", + "/workspaces/{workspace_id}/apps/imports/{import_id}:confirm", + frozenset({Trait.ACCOUNT_PRIMARY}), + ), + Route( + "app_dsl.export", + "GET", + "/apps/{app_id}/dsl", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_dsl.check_dependencies", + "GET", + "/apps/{app_id}/dependencies:check", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), + ), + Route( + "app_run.run", + "POST", + "/apps/{app_id}:run", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "app_run.stop", + "POST", + "/apps/{app_id}/tasks/{task_id}:stop", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "files.upload", + "POST", + "/apps/{app_id}/files", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "human_input_form.get", + "GET", + "/apps/{app_id}/human-input-forms/{form_token}", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "human_input_form.submit", + "POST", + "/apps/{app_id}/human-input-forms/{form_token}:submit", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "workflow_events.stream", + "GET", + "/apps/{app_id}/tasks/{task_id}/events", + frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + ), + Route( + "permitted_external.list", + "GET", + "/permitted-external-apps", + frozenset({Trait.EXTERNAL_REACHABLE, Trait.ENTERPRISE_ONLY}), + ), + Route( + "permitted_external.describe", + "GET", + "/permitted-external-apps/{app_id}", + frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE, Trait.ENTERPRISE_ONLY}), + ), +) + + +CASE_REQUIRES: dict[Case, frozenset[Trait]] = { + Case.NO_BEARER: frozenset(), + Case.MEMBER: frozenset(), + Case.WRONG_SUBJECT: frozenset(), + Case.INSUFFICIENT_SCOPE: frozenset(), + Case.NON_MEMBER: frozenset({Trait.ACCOUNT_PRIMARY}), + # frozenset() rather than {ACCOUNT_PRIMARY}: this one also has to reach the + # SSO-only routes, to pin that they answer on the subject axis, not this one. + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: frozenset(), + Case.LOW_ROLE: frozenset({Trait.ACCOUNT_PRIMARY}), + Case.APP_API_DISABLED: frozenset({Trait.APP_SCOPED}), + Case.UNKNOWN_APP: frozenset({Trait.APP_SCOPED}), + Case.FOREIGN_WORKSPACE_QUERY: frozenset({Trait.APP_SCOPED}), + Case.EDITION_NOT_ENTERPRISE: frozenset({Trait.ENTERPRISE_ONLY}), + Case.LICENSE_INVALID: frozenset(), + Case.EE_LICENSE_INVALID: frozenset(), + Case.EE_ACCOUNT_PUBLIC: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_ACCOUNT_SSO_VERIFIED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_ACCOUNT_PRIVATE_ALL: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_ACCOUNT_PRIVATE_PERMITTED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_ACCOUNT_PRIVATE_REFUSED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_ACCOUNT_MODE_UNRESOLVED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}), + Case.EE_EXTERNAL_PUBLIC: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + Case.EE_EXTERNAL_SSO_VERIFIED: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + Case.EE_EXTERNAL_PRIVATE_ALL: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + Case.EE_EXTERNAL_PRIVATE: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + Case.EE_EXTERNAL_MODE_UNRESOLVED: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), + Case.RBAC_ON_LOW_ROLE: frozenset({Trait.ACCOUNT_PRIMARY}), + Case.RBAC_ON_DENIED: frozenset({Trait.ACCOUNT_PRIMARY}), +} + + +SCENARIOS: dict[Case, Scenario] = { + Case.NO_BEARER: Scenario(bearer=Bearer.NONE), + Case.MEMBER: Scenario(bearer=Bearer.PRIMARY), + Case.WRONG_SUBJECT: Scenario(bearer=Bearer.OTHER_SUBJECT), + Case.INSUFFICIENT_SCOPE: Scenario(bearer=Bearer.PRIMARY, narrow_scopes=True), + Case.NON_MEMBER: Scenario(bearer=Bearer.ACCOUNT_OUTSIDER), + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: Scenario(bearer=Bearer.ACCOUNT_OUTSIDER, narrow_scopes=True), + Case.LOW_ROLE: Scenario(bearer=Bearer.ACCOUNT_LOW_ROLE), + Case.APP_API_DISABLED: Scenario(bearer=Bearer.PRIMARY, app_api_enabled=False), + Case.UNKNOWN_APP: Scenario(bearer=Bearer.PRIMARY, unknown_app=True), + Case.FOREIGN_WORKSPACE_QUERY: Scenario(bearer=Bearer.PRIMARY, foreign_workspace_query=True), + Case.EDITION_NOT_ENTERPRISE: Scenario(bearer=Bearer.PRIMARY, edition=DeploymentEdition.COMMUNITY), + Case.LICENSE_INVALID: Scenario(bearer=Bearer.PRIMARY, license_status=LicenseStatus.EXPIRED), + Case.EE_LICENSE_INVALID: Scenario( + bearer=Bearer.PRIMARY, edition=DeploymentEdition.ENTERPRISE, license_status=LicenseStatus.EXPIRED + ), + Case.EE_ACCOUNT_PUBLIC: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PUBLIC, + ), + Case.EE_ACCOUNT_SSO_VERIFIED: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.SSO_VERIFIED, + ), + Case.EE_ACCOUNT_PRIVATE_ALL: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PRIVATE_ALL, + ), + Case.EE_ACCOUNT_PRIVATE_PERMITTED: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PRIVATE, + private_app_permitted=True, + ), + Case.EE_ACCOUNT_PRIVATE_REFUSED: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PRIVATE, + private_app_permitted=False, + ), + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=False, + access_mode=WebAppAccessMode.PRIVATE, + private_app_permitted=False, + ), + Case.EE_ACCOUNT_MODE_UNRESOLVED: Scenario( + bearer=Bearer.ACCOUNT_MEMBER, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=None, + ), + Case.EE_EXTERNAL_PUBLIC: Scenario( + bearer=Bearer.EXTERNAL, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PUBLIC, + ), + Case.EE_EXTERNAL_SSO_VERIFIED: Scenario( + bearer=Bearer.EXTERNAL, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.SSO_VERIFIED, + ), + Case.EE_EXTERNAL_PRIVATE_ALL: Scenario( + bearer=Bearer.EXTERNAL, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PRIVATE_ALL, + ), + Case.EE_EXTERNAL_PRIVATE: Scenario( + bearer=Bearer.EXTERNAL, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=WebAppAccessMode.PRIVATE, + ), + Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: Scenario( + bearer=Bearer.EXTERNAL, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=False, + access_mode=WebAppAccessMode.PRIVATE, + private_app_permitted=False, + ), + Case.EE_EXTERNAL_MODE_UNRESOLVED: Scenario( + bearer=Bearer.EXTERNAL, + edition=DeploymentEdition.ENTERPRISE, + webapp_auth=True, + access_mode=None, + ), + Case.RBAC_ON_LOW_ROLE: Scenario(bearer=Bearer.ACCOUNT_LOW_ROLE, rbac_enabled=True, rbac_allows=True), + Case.RBAC_ON_DENIED: Scenario(bearer=Bearer.ACCOUNT_MEMBER, rbac_enabled=True, rbac_allows=False), +} + + +ROUTER_CASE_ROUTE = "account.get" +ROUTER_CASES: dict[Case, Expect] = { + Case.NO_BEARER: DENY_NO_BEARER, + Case.LICENSE_INVALID: ADMIT_NO_LICENCE_GATE, + Case.EE_LICENSE_INVALID: DENY_LICENSE, +} +"""Answered by the router before any route-specific requirement runs, so pinned once.""" + + +_ACCOUNT_ONLY_NO_WORKSPACE: dict[Case, Expect] = { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: ADMIT, + # No live membership check on this route, so the outsider's only failure is + # scope — same answer pre- and post-PR. + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.LOW_ROLE: ADMIT, + Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE, + Case.RBAC_ON_DENIED: ADMIT_NO_RBAC_PERMISSION, +} + +_ACCOUNT_MEMBER_NO_ROLE: dict[Case, Expect] = { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: DENY_NON_MEMBER, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, + Case.LOW_ROLE: ADMIT, + Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE, + Case.RBAC_ON_DENIED: ADMIT_NO_RBAC_PERMISSION, +} + +_ACCOUNT_MEMBER_WITH_ROLE: dict[Case, Expect] = { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: DENY_NON_MEMBER, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, + Case.LOW_ROLE: DENY_ROLE, + Case.RBAC_ON_LOW_ROLE: ADMIT, + Case.RBAC_ON_DENIED: DENY_RBAC, +} + +_DUAL_SUBJECT_RUN: dict[Case, Expect] = { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_SSO_NEEDS_EE, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: DENY_NON_MEMBER, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, + Case.LOW_ROLE: ADMIT, + Case.APP_API_DISABLED: DENY_API_DISABLED, + Case.UNKNOWN_APP: DENY_UNKNOWN_APP, + Case.FOREIGN_WORKSPACE_QUERY: ADMIT, + Case.EE_ACCOUNT_PUBLIC: ADMIT, + Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT, + Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED: DENY_PRIVATE_APP, + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_PRIVATE_APP, + Case.EE_ACCOUNT_MODE_UNRESOLVED: DENY_MODE_UNRESOLVED, + Case.EE_EXTERNAL_PUBLIC: ADMIT, + Case.EE_EXTERNAL_SSO_VERIFIED: ADMIT, + Case.EE_EXTERNAL_PRIVATE_ALL: DENY_ACCESS_MODE, + Case.EE_EXTERNAL_PRIVATE: DENY_ACCESS_MODE, + Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_PRIVATE_APP, + Case.EE_EXTERNAL_MODE_UNRESOLVED: DENY_MODE_UNRESOLVED, + Case.RBAC_ON_DENIED: DENY_RBAC, + Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE, +} + + +MATRIX: dict[str, dict[Case, Expect]] = { + "account.get": dict(_ACCOUNT_ONLY_NO_WORKSPACE), + "workspaces.list": dict(_ACCOUNT_ONLY_NO_WORKSPACE), + "apps.list": dict(_ACCOUNT_MEMBER_NO_ROLE), + "workspaces.switch": dict(_ACCOUNT_MEMBER_NO_ROLE), + # invite/remove check WORKSPACE_MEMBER_MANAGE; update_role checks WORKSPACE_ROLE_MANAGE + # (matches check_member_permission's own add/remove vs update split) + "workspaces.members.invite": dict(_ACCOUNT_MEMBER_WITH_ROLE), + "workspaces.members.update_role": dict(_ACCOUNT_MEMBER_WITH_ROLE), + "app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE), + "apps.describe": { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: DENY_NON_MEMBER, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, + Case.LOW_ROLE: ADMIT, + Case.APP_API_DISABLED: DENY_API_DISABLED, + Case.UNKNOWN_APP: DENY_UNKNOWN_APP, + Case.FOREIGN_WORKSPACE_QUERY: ADMIT, + Case.EE_ACCOUNT_PUBLIC: ADMIT, + Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT, + Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT, + Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT, + Case.RBAC_ON_DENIED: DENY_RBAC, + Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE, + }, + "app_dsl.export": { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER: DENY_NON_MEMBER, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER, + Case.LOW_ROLE: DENY_ROLE, + Case.APP_API_DISABLED: DENY_API_DISABLED, + Case.UNKNOWN_APP: DENY_UNKNOWN_APP, + Case.FOREIGN_WORKSPACE_QUERY: ADMIT, + Case.EE_ACCOUNT_PUBLIC: ADMIT, + Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT, + Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT, + Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT, + Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT, + Case.RBAC_ON_LOW_ROLE: ADMIT, + Case.RBAC_ON_DENIED: DENY_RBAC, + }, + "app_run.run": dict(_DUAL_SUBJECT_RUN), + "human_input_form.get": dict(_DUAL_SUBJECT_RUN), + "files.upload": { + **_DUAL_SUBJECT_RUN, + Case.RBAC_ON_DENIED: ADMIT_NO_RBAC_PERMISSION, + }, + "permitted_external.list": { + Case.MEMBER: ADMIT_NO_MOUNT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + # An account bearer answers on the subject axis here, before scope or + # membership: this route's CheckSubject allows only ExternalSsoSubject. + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_WRONG_SUBJECT, + Case.EDITION_NOT_ENTERPRISE: DENY_EDITION, + }, + "permitted_external.describe": { + Case.MEMBER: ADMIT, + Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT, + Case.INSUFFICIENT_SCOPE: DENY_SCOPE, + Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_WRONG_SUBJECT, + Case.EDITION_NOT_ENTERPRISE: DENY_EDITION, + Case.APP_API_DISABLED: DENY_API_DISABLED, + Case.UNKNOWN_APP: DENY_UNKNOWN_APP, + Case.FOREIGN_WORKSPACE_QUERY: Expect( + ADMITTED, + note="a foreign ?workspace_id= is ignored on every app-scoped route (spec 2.9)", + ), + Case.EE_EXTERNAL_PUBLIC: ADMIT, + Case.EE_EXTERNAL_SSO_VERIFIED: ADMIT, + Case.EE_EXTERNAL_PRIVATE_ALL: DENY_ACCESS_MODE, + Case.EE_EXTERNAL_PRIVATE: DENY_ACCESS_MODE, + Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_PRIVATE_APP, + Case.EE_EXTERNAL_MODE_UNRESOLVED: DENY_MODE_UNRESOLVED, + }, +} +"""One representative per distinct declared requirement tuple; `DECLARED` pins the rest.""" + + +_ACCOUNT = (AccountSubject,) +_ACCOUNT_OR_EXTERNAL = (AccountSubject, ExternalSsoSubject) +_OWNER_ADMIN = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}) +_EDITOR_UP = frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}) + +_REQ_ACCOUNT_FULL = (CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.FULL)) +_REQ_ACCOUNT_WORKSPACE_READ = (CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_READ)) +_REQ_ACCOUNT_APPS_READ_MEMBER = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.APPS_READ), + CheckWorkspaceMember(), +) +_REQ_ACCOUNT_WORKSPACE_READ_MEMBER = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_READ), + CheckWorkspaceMember(), +) +_REQ_MEMBER_MANAGE = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())), + CheckWorkspaceRole(_OWNER_ADMIN), +) +_REQ_ROLE_MANAGE = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())), + CheckWorkspaceRole(_OWNER_ADMIN), +) +_REQ_DSL_WORKSPACE = ( + CheckSubject(allowed=_ACCOUNT), + CheckScope(Scope.WORKSPACE_WRITE), + CheckWorkspaceMember(), + CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())), + CheckWorkspaceRole(_EDITOR_UP), +) +_REQ_DSL_APP = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())), + CheckWorkspaceRole(_EDITOR_UP), +) +_REQ_APP_DESCRIBE = ( + CheckSubject(allowed=_ACCOUNT), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_READ), + CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())), +) +_REQ_RUN = ( + CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), + CheckAppAccess(), +) +_REQ_RUN_FORM = (*_REQ_RUN, CheckFormSurface()) +_REQ_FILES = ( + CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL), + CheckAppApiEnabled(), + CheckWorkspaceMember(), + CheckScope(Scope.APPS_RUN), + CheckAppAccess(), +) +_REQ_EXTERNAL_LIST = ( + CheckSubject(allowed=(ExternalSsoSubject,)), + CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL), +) +_REQ_EXTERNAL_DESCRIBE = ( + CheckSubject(allowed=(ExternalSsoSubject,)), + CheckAppApiEnabled(), + CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL), + CheckAppAccess(), +) + +DECLARED: dict[str, tuple[Requirement, ...]] = { + "account.get": _REQ_ACCOUNT_FULL, + "account.sessions.revoke_self": _REQ_ACCOUNT_FULL, + "account.sessions.list": _REQ_ACCOUNT_FULL, + "account.sessions.revoke_one": _REQ_ACCOUNT_FULL, + "apps.describe": _REQ_APP_DESCRIBE, + "apps.list": _REQ_ACCOUNT_APPS_READ_MEMBER, + "workspaces.list": _REQ_ACCOUNT_WORKSPACE_READ, + "workspaces.describe": _REQ_ACCOUNT_WORKSPACE_READ, + "workspaces.switch": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "workspaces.members.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, + "workspaces.members.invite": _REQ_MEMBER_MANAGE, + "workspaces.members.remove": _REQ_MEMBER_MANAGE, + "workspaces.members.update_role": _REQ_ROLE_MANAGE, + "app_dsl.import": _REQ_DSL_WORKSPACE, + "app_dsl.import_confirm": _REQ_DSL_WORKSPACE, + "app_dsl.export": _REQ_DSL_APP, + "app_dsl.check_dependencies": _REQ_DSL_APP, + "app_run.run": _REQ_RUN, + "app_run.stop": _REQ_RUN, + "files.upload": _REQ_FILES, + "human_input_form.get": _REQ_RUN_FORM, + "human_input_form.submit": _REQ_RUN_FORM, + "workflow_events.stream": _REQ_RUN, + "permitted_external.list": _REQ_EXTERNAL_LIST, + "permitted_external.describe": _REQ_EXTERNAL_DESCRIBE, +} +"""Every route's `@endpoint(requirements=...)`, in declared order. A route that +shares a tuple with a `MATRIX` representative is covered by that representative's +rows only while this table says it still declares the same tuple.""" + + +ROUTES_BY_ID = {route.id: route for route in ROUTES} + + +def _applicable(route: Route, case: Case) -> bool: + return CASE_REQUIRES[case] <= route.traits + + +def _rows(route: Route) -> Iterator[tuple[Route, Case, Expect]]: + for case in Case: + if not _applicable(route, case): + continue + if case not in ROUTER_CASES: + yield route, case, MATRIX[route.id][case] + elif route.id == ROUTER_CASE_ROUTE: + yield route, case, ROUTER_CASES[case] + + +ROWS: tuple[tuple[Route, Case, Expect], ...] = tuple( + row for route in ROUTES if route.id in MATRIX for row in _rows(route) +) + + +@dataclass(slots=True) +class World: + """Persisted rows and the tokens that address them.""" + + workspace_id: str + other_workspace_id: str + app_id: str + disabled_app_id: str + member_account_id: str + low_role_account_id: str + outsider_account_id: str + tokens: dict[Bearer, str] = field(default_factory=dict) + session_ids: dict[Bearer, str] = field(default_factory=dict) + + +def _admit_on_mount(*_args: object, **_kwargs: object) -> None: + raise ImATeapot("admitted") + + +class _MemoryResolver: + """Stands in for the DB-backed token resolver. + + Token resolution is an input to the matrix, not part of it, and the shipped + resolver compares a timezone-aware expiry against a column SQLite hands back + naive. + """ + + def __init__(self, rows: dict[str, ResolvedRow]) -> None: + self._rows = rows + + def resolve(self, token_hash: str) -> ResolvedRow | None: + return self._rows.get(token_hash) + + +def _authenticator(rows: dict[str, ResolvedRow]) -> BearerAuthenticator: + resolver = _MemoryResolver(rows) + return BearerAuthenticator({TokenType.OAUTH_ACCOUNT: resolver, TokenType.OAUTH_EXTERNAL_SSO: resolver}) + + +@pytest.fixture +def token_rows() -> dict[str, ResolvedRow]: + return {} + + +@pytest.fixture +def matrix_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Flask]: + """The openapi blueprint on the real factory app with a login manager and the admission probe.""" + app = create_flask_app_with_configs() + app.config["TESTING"] = True + app.secret_key = "openapi-auth-matrix" + LoginManager(app) + app.register_blueprint(openapi_bp) + + monkeypatch.setattr( + rate_limit_module, + "LIMIT_BEARER_PER_TOKEN", + rate_limit_module.RateLimit( + 0, + rate_limit_module.LIMIT_BEARER_PER_TOKEN.window, + rate_limit_module.LIMIT_BEARER_PER_TOKEN.scopes, + ), + ) + + user_logged_in.connect(_admit_on_mount) + try: + yield app + finally: + user_logged_in.disconnect(_admit_on_mount) + + +@pytest.fixture +def world(sqlite_session_factory: sessionmaker[Session], token_rows: dict[str, ResolvedRow]) -> World: + """One workspace with an owner, a normal member and an outsider; two apps.""" + built = World( + workspace_id=str(uuid.uuid4()), + other_workspace_id=str(uuid.uuid4()), + app_id=str(uuid.uuid4()), + disabled_app_id=str(uuid.uuid4()), + member_account_id=str(uuid.uuid4()), + low_role_account_id=str(uuid.uuid4()), + outsider_account_id=str(uuid.uuid4()), + ) + + def account(account_id: str, email: str) -> Account: + row = Account(name=email, email=email, avatar="", status=AccountStatus.ACTIVE) + row.id = account_id + return row + + def application(app_id: str, *, enable_api: bool) -> App: + return App( + id=app_id, + tenant_id=built.workspace_id, + name="matrix app", + description="", + mode="workflow", + enable_site=False, + enable_api=enable_api, + ) + + workspace = Tenant(name="matrix workspace") + workspace.id = built.workspace_id + other_workspace = Tenant(name="other workspace") + other_workspace.id = built.other_workspace_id + + with sqlite_session_factory() as session: + session.add_all( + [ + workspace, + other_workspace, + account(built.member_account_id, "owner@example.com"), + account(built.low_role_account_id, "normal@example.com"), + account(built.outsider_account_id, "outsider@example.com"), + application(built.app_id, enable_api=True), + application(built.disabled_app_id, enable_api=False), + TenantAccountJoin( + tenant_id=built.workspace_id, + account_id=built.member_account_id, + role=TenantAccountRole.OWNER, + ), + TenantAccountJoin( + tenant_id=built.workspace_id, + account_id=built.low_role_account_id, + role=TenantAccountRole.NORMAL, + ), + ] + ) + session.commit() + + def mint(bearer: Bearer, prefix: str, *, account_id: str | None, email: str) -> None: + """Register a bearer with the fake resolver *and* persist the session row it + names, so the access service's ownership check has something real to match against. + """ + raw = prefix + uuid.uuid4().hex + token_id = uuid.uuid4() + issuer = "dify:account" if account_id else "https://idp.example" + token_rows[sha256_hex(raw)] = ResolvedRow( + subject_email=email, + subject_issuer=issuer, + account_id=uuid.UUID(account_id) if account_id else None, + client_id="difyctl", + token_id=token_id, + expires_at=None, + ) + row = OAuthAccessToken( + token_hash=sha256_hex(raw), + prefix=prefix, + account_id=account_id, + subject_email=email, + subject_issuer=issuer, + client_id="difyctl", + device_label="matrix", + expires_at=datetime.now(UTC) + timedelta(days=365), + ) + row.id = str(token_id) + with sqlite_session_factory() as session: + session.add(row) + session.commit() + built.tokens[bearer] = raw + built.session_ids[bearer] = str(token_id) + + mint( + Bearer.ACCOUNT_MEMBER, + TokenType.OAUTH_ACCOUNT.prefix, + account_id=built.member_account_id, + email="owner@example.com", + ) + mint( + Bearer.ACCOUNT_LOW_ROLE, + TokenType.OAUTH_ACCOUNT.prefix, + account_id=built.low_role_account_id, + email="normal@example.com", + ) + mint( + Bearer.ACCOUNT_OUTSIDER, + TokenType.OAUTH_ACCOUNT.prefix, + account_id=built.outsider_account_id, + email="outsider@example.com", + ) + mint(Bearer.EXTERNAL, TokenType.OAUTH_EXTERNAL_SSO.prefix, account_id=None, email="external@example.com") + return built + + +def _system_features( + *, edition: DeploymentEdition, webapp_auth: bool, license_status: LicenseStatus +) -> SystemFeatureModel: + features = SystemFeatureModel(deployment_edition=edition) + features.webapp_auth.enabled = webapp_auth + features.license.status = license_status + return features + + +@dataclass(frozen=True, slots=True) +class _WebAppSettings: + access_mode: str + + +def _access_mode_settings(access_mode: WebAppAccessMode | None) -> _WebAppSettings | None: + if access_mode is None: + return None + return _WebAppSettings(access_mode=access_mode.value) + + +def _webapp_account() -> Account: + """The account an external-SSO subject's email resolves to for the private-app check.""" + row = Account(name="external", email="external@example.com", avatar="", status=AccountStatus.ACTIVE) + row.id = str(uuid.uuid4()) + return row + + +def _end_user(_type: EndUserType, tenant_id: str, app_id: str, user_id: str | None = None) -> EndUser: + row = EndUser( + tenant_id=tenant_id, + app_id=app_id, + type=EndUserType.OPENAPI, + is_anonymous=False, + session_id=user_id or "", + ) + row.external_user_id = user_id + return row + + +def _bearer_for(route: Route, scenario: Scenario) -> Bearer | None: + account_primary = Trait.ACCOUNT_PRIMARY in route.traits + match scenario.bearer: + case Bearer.NONE: + return None + case Bearer.PRIMARY: + return Bearer.ACCOUNT_MEMBER if account_primary else Bearer.EXTERNAL + case Bearer.OTHER_SUBJECT: + return Bearer.EXTERNAL if account_primary else Bearer.ACCOUNT_MEMBER + case _: + return scenario.bearer + + +def _url(route: Route, world: World, scenario: Scenario, bearer: Bearer | None) -> str: + if scenario.unknown_app: + app_id = str(uuid.uuid4()) + elif not scenario.app_api_enabled: + app_id = world.disabled_app_id + else: + app_id = world.app_id + ids = { + "app_id": app_id, + "workspace_id": world.workspace_id, + # The caller's own session: the access service 404s any other id. + "session_id": world.session_ids.get(bearer, str(uuid.uuid4())) if bearer else str(uuid.uuid4()), + "member_id": str(uuid.uuid4()), + "import_id": str(uuid.uuid4()), + "task_id": str(uuid.uuid4()), + "form_token": uuid.uuid4().hex, + } + query = route.query.format(**ids) + if scenario.foreign_workspace_query: + extra = f"workspace_id={world.other_workspace_id}" + query = f"{query}&{extra}" if query else extra + path = route.path.format(**ids) + return f"/openapi/v1{path}?{query}" if query else f"/openapi/v1{path}" + + +def _run_case( + *, + route: Route, + scenario: Scenario, + app: Flask, + world: World, + token_rows: dict[str, ResolvedRow], + monkeypatch: pytest.MonkeyPatch, +) -> TestResponse: + + edition = scenario.edition or ( + DeploymentEdition.ENTERPRISE if Trait.ENTERPRISE_ONLY in route.traits else DeploymentEdition.COMMUNITY + ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=edition, RBAC_ENABLED=scenario.rbac_enabled) + monkeypatch.setattr(oauth_bearer_module, "_authenticator", _authenticator(token_rows)) + if scenario.narrow_scopes: + # No shipped token can fail `CheckScope`: `dfoa_` carries `Scope.FULL` and + # `dfoe_` exactly the scopes its routes ask for. Scopes are a constant of + # the subject, so the only way to reach the refusal is to blank them here. + # Keep these rows: they are what catches a third token kind routed + # somewhere it was not scoped for. + monkeypatch.setattr(oauth_bearer_module.AuthContext, "scopes", property(lambda _self: frozenset())) + + features = _system_features( + edition=edition, webapp_auth=scenario.webapp_auth, license_status=scenario.license_status + ) + settings = _access_mode_settings(scenario.access_mode) + + headers: dict[str, str] = {} + bearer = _bearer_for(route, scenario) + if bearer is not None: + headers["Authorization"] = f"Bearer {world.tokens[bearer]}" + + with ExitStack() as stack: + stack.enter_context(patch.object(SystemFeatureService, "get_public_system_features", return_value=features)) + stack.enter_context( + patch.object(EnterpriseService.WebAppAuth, "get_app_access_mode_by_id", return_value=settings) + ) + stack.enter_context( + patch.object( + EnterpriseService.WebAppAuth, + "is_user_allowed_to_access_webapp", + return_value=scenario.private_app_permitted, + ) + ) + stack.enter_context( + patch.object( + EnterpriseService.WebAppAuth, + "list_externally_accessible_apps", + return_value={"data": [], "total": 0, "hasMore": False}, + ) + ) + stack.enter_context( + patch.object( + EndUserService, + "get_or_create_end_user_by_type", + side_effect=_end_user, + ) + ) + stack.enter_context(patch.object(RBACResourceService, "get_app_agent_binding", return_value=None)) + stack.enter_context(patch.object(RBACResourceService, "get_app_maintainer", return_value=None)) + stack.enter_context( + patch( + "services.enterprise.rbac_service.RBACService.CheckAccess.check", + return_value=scenario.rbac_allows, + ) + ) + stack.enter_context(patch.object(AccountService, "get_account_by_email", return_value=_webapp_account())) + client = app.test_client() + return contextvars.copy_context().run( + lambda: client.open(_url(route, world, scenario, bearer), method=route.method, headers=headers) + ) + + +@pytest.mark.parametrize( + ("route", "case", "expected"), + ROWS, + ids=[f"{route.id}-{case.value}" for route, case, _ in ROWS], +) +def test_allow_deny_matrix( + route: Route, + case: Case, + expected: Expect, + matrix_app: Flask, + world: World, + token_rows: dict[str, ResolvedRow], + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = _run_case( + route=route, + scenario=SCENARIOS[case], + app=matrix_app, + world=world, + token_rows=token_rows, + monkeypatch=monkeypatch, + ) + + body = response.get_json(silent=True) or {} + assert response.status_code == expected.status, f"{route.id}/{case.value}: body={body}" + if expected.message is not None: + assert body.get("message") == expected.message + + +def _rule_path(route: Route) -> str: + return "/openapi/v1" + route.path.replace("{", "") + + +def _endpoint_spec(app: Flask, rule: Rule, method: str) -> EndpointSpec | None: + """The `EndpointSpec` `@endpoint` attached to this rule's handler, or None. + + Carrying a spec *is* being guarded: `@endpoint` is the only thing that sets + it, and it sets it on the same object it wraps in `subject_router.guard`. + """ + view = app.view_functions.get(rule.endpoint) + resource = view.view_class if hasattr(view, "view_class") else None + if resource is None: + return None + handler = getattr(resource, method.lower(), None) # guard-ignore: no-new-getattr -- HTTP verb selects the handler + return handler.__spec__ if hasattr(handler, "__spec__") else None + + +def test_registered_openapi_routes_match_the_matrix(matrix_app: Flask) -> None: + """Every guarded /openapi/v1 route is in the table, and nothing else is. + + Guarded-ness is derived from `view.__spec__`, not from a hand-kept list of + exemptions — so a route that gains a guard without gaining a row fails here, + and so does a guarded route that quietly stops carrying a spec. Neither can + be silenced by editing this file. + """ + guarded: set[tuple[str, str]] = set() + unguarded: set[tuple[str, str]] = set() + for rule in matrix_app.url_map.iter_rules(): + if not str(rule).startswith("/openapi/v1") or str(rule) == "/openapi/v1/": + continue + for method in rule.methods or set(): + if method in {"HEAD", "OPTIONS"}: + continue + entry = (method, str(rule)) + target = guarded if _endpoint_spec(matrix_app, rule, method) is not None else unguarded + target.add(entry) + + expected = {(route.method, _rule_path(route)) for route in ROUTES} + assert guarded == expected + # The remainder is the device-flow and documentation surface. Its size is pinned + # rather than enumerated: a list of exemptions rots silently — the one this + # replaced still named `swagger.json`, a route that is not registered at all. + assert len(unguarded) == 13 + + +@singledispatch +def _config(_requirement: Requirement) -> object: + return None + + +@_config.register +def _(requirement: CheckSubject) -> object: + return requirement.allowed + + +@_config.register +def _(requirement: CheckScope) -> object: + return requirement.scope + + +@_config.register +def _(requirement: CheckRBACPermission) -> object: + return tuple((check.scene, repr(check.locator)) for check in requirement.checks) + + +@_config.register +def _(requirement: CheckWorkspaceRole) -> object: + return requirement.allowed_roles + + +def _canonical(requirement: Requirement) -> tuple[type[Requirement], object]: + """`Requirement` has no `__eq__`, so compare by type plus the config that sets it apart.""" + return type(requirement), _config(requirement) + + +def _spec_for(app: Flask, route: Route) -> EndpointSpec | None: + for rule in app.url_map.iter_rules(): + if str(rule) == _rule_path(route) and route.method in (rule.methods or set()): + return _endpoint_spec(app, rule, route.method) + return None + + +@pytest.mark.parametrize("route", ROUTES, ids=[route.id for route in ROUTES]) +def test_route_declares_expected_requirements(route: Route, matrix_app: Flask) -> None: + """The guard that lets `MATRIX` run one representative per tuple: a route + that drifts from the tuple its representative was chosen for fails here. + """ + spec = _spec_for(matrix_app, route) + assert spec is not None, route.id + assert [*map(_canonical, spec.requirements)] == [*map(_canonical, DECLARED[route.id])] + + +ERROR_DEFAULT_RESPONSE: dict[str, object] = { + "description": "Error", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorBody"}}}, +} +"""Exactly what `@returns` registers as `("default", "Error", ErrorBody)`.""" + +EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { + ("get", "/_health"): frozenset({"200", "default"}), + ("get", "/_version"): frozenset({"200", "default"}), + ("get", "/account"): frozenset({"200", "default"}), + ("get", "/account/sessions"): frozenset({"200", "422", "default"}), + ("delete", "/account/sessions/self"): frozenset({"200", "default"}), + ("delete", "/account/sessions/{session_id}"): frozenset({"200", "default"}), + ("get", "/apps"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/dependencies:check"): frozenset({"200", "default"}), + ("get", "/apps/{app_id}/dsl"): frozenset({"200", "422", "default"}), + ("post", "/apps/{app_id}/files"): frozenset({"201", "400", "401", "413", "415", "default"}), + ("get", "/apps/{app_id}/human-input-forms/{form_token}"): frozenset({"200", "default"}), + ("post", "/apps/{app_id}/human-input-forms/{form_token}:submit"): frozenset({"200", "422", "default"}), + ("get", "/apps/{app_id}/tasks/{task_id}/events"): frozenset({"200", "default"}), + ("post", "/apps/{app_id}/tasks/{task_id}:stop"): frozenset({"200", "default"}), + ("post", "/apps/{app_id}:run"): frozenset({"200", "422", "default"}), + # The five device-flow rows are the only operations with no `default`: they + # document their 200 with a raw `openapi_ns.response` rather than `@returns`, + # so no `ErrorBody` schema is registered for them. + ("post", "/oauth/device/approve"): frozenset({"200"}), + ("post", "/oauth/device/code"): frozenset({"200"}), + ("post", "/oauth/device/deny"): frozenset({"200"}), + ("get", "/oauth/device/lookup"): frozenset({"200"}), + ("post", "/oauth/device/token"): frozenset({"200"}), + ("get", "/permitted-external-apps"): frozenset({"200", "422", "default"}), + ("get", "/permitted-external-apps/{app_id}"): frozenset({"200", "422", "default"}), + ("get", "/workspaces"): frozenset({"200", "default"}), + ("get", "/workspaces/{workspace_id}"): frozenset({"200", "default"}), + ("post", "/workspaces/{workspace_id}/apps/imports"): frozenset({"200", "202", "400", "422", "default"}), + ("post", "/workspaces/{workspace_id}/apps/imports/{import_id}:confirm"): frozenset({"200", "400", "default"}), + ("get", "/workspaces/{workspace_id}/members"): frozenset({"200", "422", "default"}), + ("post", "/workspaces/{workspace_id}/members"): frozenset({"201", "422", "default"}), + ("delete", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "default"}), + ("patch", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "422", "default"}), + ("post", "/workspaces/{workspace_id}:switch"): frozenset({"200", "default"}), +} + +_HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"}) + + +@pytest.fixture +def openapi_document(config_overrides: Callable[..., None]) -> dict[str, object]: + config_overrides(SWAGGER_UI_ENABLED=True) + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + response = app.test_client().get("/openapi/v1/openapi.json") + assert response.status_code == 200 + return response.get_json() + + +def _operations(document: dict[str, object]) -> Iterator[tuple[tuple[str, str], dict[str, object]]]: + paths = document["paths"] + assert isinstance(paths, dict) + for path, item in paths.items(): + assert isinstance(item, dict) + for method, operation in item.items(): + if method in _HTTP_METHODS: + assert isinstance(operation, dict) + yield (method, path), operation + + +def test_openapi_document_operations_and_response_codes(openapi_document: dict[str, object]) -> None: + """Every operation's response codes are pinned exactly. + + `@endpoint` routes through `@returns`, which registers `default` -> `ErrorBody`, + so a route that stops emitting the error schema (or one that starts) moves a row + here and has to be re-pinned deliberately. + """ + seen = dict(_operations(openapi_document)) + assert set(seen) == set(EXPECTED_RESPONSE_CODES) + for key, operation in seen.items(): + responses = operation.get("responses", {}) + assert isinstance(responses, dict) + assert frozenset(responses) == EXPECTED_RESPONSE_CODES[key], ( + f"{key}: {sorted(responses)} != {sorted(EXPECTED_RESPONSE_CODES[key])}" + ) + + +def test_every_default_response_is_the_one_returns_registers(openapi_document: dict[str, object]) -> None: + """A hand-written `default` pointing at some other schema would still satisfy + the code sets above, which compare codes and not bodies. + """ + for key, operation in _operations(openapi_document): + responses = operation["responses"] + assert isinstance(responses, dict) + if "default" in responses: + assert responses["default"] == ERROR_DEFAULT_RESPONSE, key diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index 69263a5d929..bb0fa8a7386 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -45,7 +45,7 @@ def _register_contract_test_models(): def _guard_like(view): - """Stand-in for ``@auth_router.guard`` — an outermost @wraps layer.""" + """Stand-in for ``@subject_router.guard`` — an outermost @wraps layer.""" @wraps(view) def wrapper(*args, **kwargs): @@ -54,32 +54,20 @@ def _guard_like(view): return wrapper -def test_accepts_injects_validated_query(app): +def test_accepts_injects_validated_query_with_defaults_for_absent_fields(app): @accepts(query=ContractQuery) def view(*, query): return query - with app.test_request_context("/?page=3&limit=5"): + with app.test_request_context("/?page=3"): result = view() assert isinstance(result, ContractQuery) assert result.page == 3 - assert result.limit == 5 - - -def test_accepts_query_uses_defaults_when_absent(app): - @accepts(query=ContractQuery) - def view(*, query): - return query - - with app.test_request_context("/"): - result = view() - - assert result.page == 1 assert result.limit == 20 -@pytest.mark.parametrize("query_string", ["page=0", "limit=999", "page=abc", "unknown=1"]) +@pytest.mark.parametrize("query_string", ["page=abc", "unknown=1"]) def test_accepts_rejects_invalid_query_with_422(app, query_string): @accepts(query=ContractQuery) def view(*, query): @@ -124,16 +112,6 @@ def test_accepts_injects_validated_body(app): assert result.name == "x" -def test_accepts_rejects_invalid_body_with_422(app): - @accepts(body=ContractBody) - def view(*, body): - return body - - with app.test_request_context("/", method="POST", json={"wrong": 1}): - with pytest.raises(UnprocessableEntity): - view() - - def test_returns_serializes_model_with_decorator_status(app): @returns(200, ContractResp) def view(): @@ -146,16 +124,17 @@ def test_returns_serializes_model_with_decorator_status(app): assert body == {"value": 7} -def test_returns_serializes_model_in_tuple_and_honors_status(app): +@pytest.mark.parametrize("trailing", [(201,), (202, {"X-Test": "1"})], ids=["status", "status_and_headers"]) +def test_returns_serializes_model_in_tuple_and_keeps_trailing_parts(app, trailing): @returns(200, ContractResp) def view(): - return ContractResp(value=9), 201 + return ContractResp(value=9), *trailing with app.test_request_context("/"): - body, status = view() + body, *rest = view() - assert status == 201 assert body == {"value": 9} + assert tuple(rest) == trailing def test_returns_passes_through_non_model(app): @@ -171,79 +150,17 @@ def test_returns_passes_through_non_model(app): assert result is sentinel -def test_returns_serializes_model_in_three_tuple_with_headers(app): - """A (model, status, headers) tuple keeps its trailing status/headers intact.""" - - @returns(200, ContractResp) - def view(): - return ContractResp(value=3), 202, {"X-Test": "1"} - - with app.test_request_context("/"): - body, status, headers = view() - - assert body == {"value": 3} - assert status == 202 - assert headers == {"X-Test": "1"} - - # Swagger metadata (read off __apidoc__) must survive @wraps up through the guard layer. def test_accepts_returns_emit_apidoc_through_guard_stack(): @_guard_like @returns(200, ContractResp) - @accepts(query=ContractQuery) - def view(*, query): + @accepts(query=ContractQuery, body=ContractBody) + def view(*, query, body): return ContractResp(value=1) apidoc = getattr(view, "__apidoc__", {}) assert "page" in apidoc.get("params", {}) # from @accepts(query=) + assert apidoc.get("expect") # from @accepts(body=), via @openapi_ns.expect assert "200" in apidoc.get("responses", {}) # from @returns (flask_restx keys by str code) - - -def test_accepts_body_emits_expect_through_guard_stack(): - @_guard_like - @accepts(body=ContractBody) - def view(*, body): - return body - - apidoc = getattr(view, "__apidoc__", {}) - assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect - - -def _response_model_name(entry) -> str: - """Extract the model name from a flask-restx __apidoc__ response entry. - - flask-restx stores responses as ``(description, model, kwargs)`` tuples - where ``model.name`` is the registered schema name. - """ - if isinstance(entry, tuple) and len(entry) >= 2: - model = entry[1] - return getattr(model, "name", "") or "" - return "" - - -def test_accepts_documents_422_error_response(app): - from controllers.openapi._errors import ErrorBody - - @accepts(query=ContractQuery) - def view(*, query): - return query - - doc = getattr(view, "__apidoc__", {}) - responses = doc.get("responses", {}) - assert "422" in responses - assert _response_model_name(responses["422"]) == ErrorBody.__name__ - - -def test_returns_documents_default_error_response(app): - from controllers.openapi._errors import ErrorBody - - @returns(200, ContractResp) - def view(): - return ContractResp(value=1) - - doc = getattr(view, "__apidoc__", {}) - responses = doc.get("responses", {}) - assert "default" in responses - assert _response_model_name(responses["default"]) == ErrorBody.__name__ diff --git a/api/tests/unit_tests/controllers/openapi/test_error_contract.py b/api/tests/unit_tests/controllers/openapi/test_error_contract.py index 6a30637b7ae..8d1cf8ddf04 100644 --- a/api/tests/unit_tests/controllers/openapi/test_error_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_error_contract.py @@ -1,8 +1,11 @@ """Wire-contract tests for the canonical /openapi/v1 error body.""" +import uuid +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from sqlalchemy.orm import Session from werkzeug.exceptions import ( BadGateway, BadRequest, @@ -44,6 +47,8 @@ from controllers.service_api.app.error import ( ProviderQuotaExceededError, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from libs.oauth_bearer import AuthContext, TokenType +from models.account import Account, AccountStatus @pytest.fixture @@ -82,22 +87,6 @@ class TestErrorBodyModel: class TestOpenApiErrorFormatter: - def test_plain_werkzeug_exception_maps_code_from_status(self, fmt): - e = NotFound("app not found") - data = {"code": "not_found", "message": "app not found", "status": 404} - - wire = fmt.finalize(e, data, 404) - - assert wire == {"code": "not_found", "message": "app not found", "status": 404} - - def test_422_maps_to_invalid_param(self, fmt): - e = UnprocessableEntity("workspace_id is required for name-based lookup") - data = {"code": "unprocessable_entity", "message": e.description, "status": 422} - - wire = fmt.finalize(e, data, 422) - - assert wire["code"] == "invalid_param" - def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt): # Simulates _contract.py's abort(422, message=..., errors=...): flask_restx # attaches kwargs to e.data, which handle_error would otherwise put on the @@ -142,24 +131,6 @@ class TestOpenApiErrorFormatter: assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422} - def test_base_http_exception_error_code_wins_over_status_map(self, fmt): - e = ProviderQuotaExceededError() - data = dict(e.data) - - wire = fmt.finalize(e, data, 400) - - assert wire["code"] == "provider_quota_exceeded" - assert wire["status"] == 400 - - def test_hint_attribute_is_emitted(self, fmt): - e = Conflict("seat limit") - e.hint = "remove a member first" - data = {"code": "conflict", "message": "seat limit", "status": 409} - - wire = fmt.finalize(e, data, 409) - - assert wire["hint"] == "remove a member first" - def test_params_shape_becomes_details(self, fmt): e = ValueError("is required") data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400} @@ -188,43 +159,25 @@ class TestOpenApiErrorFormatter: assert wire["code"] == "unknown" - def test_openapi_error_subclass_is_throw_and_done(self, fmt): + @pytest.mark.parametrize("message", [None, "custom reason"], ids=["class_description", "call_site_override"]) + def test_openapi_error_subclass_is_throw_and_done(self, fmt, message): # The dedicated throwable: subclass declares status + code + message once, - # call sites just `raise`; the formatter emits everything verbatim. + # call sites just `raise`, or pass a message to override the description; + # the formatter emits everything verbatim. class TeapotError(OpenApiError): code = 418 error_code = OpenApiErrorCode.INVALID_PARAM description = "kettle says no" - e = TeapotError(details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")]) + e = TeapotError(message, details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")]) data = {"code": "im_a_teapot", "message": e.description, "status": 418} wire = fmt.finalize(e, data, 418) assert wire["code"] == OpenApiErrorCode.INVALID_PARAM - assert wire["message"] == TeapotError.description + assert wire["message"] == (message or TeapotError.description) assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}] - def test_openapi_error_message_override(self, fmt): - e = OpenApiError("custom reason") - data = {"code": "bad_request", "message": e.description, "status": 400} - - wire = fmt.finalize(e, data, 400) - - assert wire["message"] == "custom reason" - assert wire["code"] == "bad_request" - - def test_every_emitted_code_is_an_enum_member(self, fmt): - # Guard against the formatter inventing codes outside the contract. - cases = [ - (NotFound("x"), {"code": "not_found", "message": "x", "status": 404}, 404), - (ProviderQuotaExceededError(), dict(ProviderQuotaExceededError().data), 400), - (ValueError("x"), {"code": "invalid_param", "message": "x", "status": 400}, 400), - ] - for e, data, status in cases: - wire = fmt.finalize(e, data, status) - assert wire["code"] in {c.value for c in OpenApiErrorCode} - class TestQuotaExceptions: @pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded]) @@ -244,12 +197,39 @@ class TestQuotaExceptions: class TestWireContract: """End-to-end: request in, canonical JSON out, through the real openapi blueprint.""" - def test_accepts_422_carries_code_status_details(self, openapi_app, bypass_pipeline): - client = openapi_app.test_client() + def test_accepts_422_carries_code_status_details( + self, openapi_app, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch + ): + """`@endpoint` runs auth outermost, so reaching `@accepts` means passing + it: one account row and a stubbed authenticator are the whole world + `GET /account/sessions` needs. + """ + account_id = uuid.uuid4() + account = Account(name="caller", email="caller@example.com", status=AccountStatus.ACTIVE) + account.id = str(account_id) + sqlite_session.add(account) + sqlite_session.commit() - resp = client.get("/openapi/v1/apps?page=0") + token = AuthContext( + subject_email="caller@example.com", + subject_issuer="dify:account", + account_id=account_id, + client_id="difyctl", + token_id=uuid.uuid4(), + token_type=TokenType.OAUTH_ACCOUNT, + expires_at=None, + ) + monkeypatch.setattr( + "controllers.openapi.auth.router.get_authenticator", + lambda: SimpleNamespace(authenticate=lambda _token: token), + ) + monkeypatch.setattr("controllers.openapi.auth.pipelines._mount_flask_login", lambda _user: None) - assert resp.status_code == 422 + resp = openapi_app.test_client().get( + "/openapi/v1/account/sessions?page=0", headers={"Authorization": "Bearer dfoa_wire"} + ) + + assert resp.status_code == 422, resp.get_json() wire = resp.get_json() ErrorBody.model_validate(wire) assert wire["code"] == "invalid_param" diff --git a/api/tests/unit_tests/controllers/openapi/test_files.py b/api/tests/unit_tests/controllers/openapi/test_files.py index 2ced95bfea4..c950b35f2af 100644 --- a/api/tests/unit_tests/controllers/openapi/test_files.py +++ b/api/tests/unit_tests/controllers/openapi/test_files.py @@ -1,4 +1,3 @@ -from inspect import unwrap from io import BytesIO from types import SimpleNamespace from unittest.mock import Mock @@ -12,27 +11,14 @@ from controllers.common.errors import ( FileTooLargeError, UnsupportedFileTypeError, ) -from controllers.openapi.auth.data import AuthData, CallerKind from controllers.openapi.files import AppFileUploadApi from libs.exception import BaseHTTPException -from libs.oauth_bearer import Scope, TokenType from models import Account from services.errors.file import BlockedFileExtensionError as ServiceBlockedFileExtensionError from services.errors.file import FileTooLargeError as ServiceFileTooLargeError from services.errors.file import UnsupportedFileTypeError as ServiceUnsupportedFileTypeError -def _auth_data(caller: Account) -> AuthData: - return AuthData.model_construct( - token_type=TokenType.OAUTH_ACCOUNT, - token_hash="test-token", - scopes=frozenset({Scope.APPS_RUN}), - app=object(), - caller=caller, - caller_kind=CallerKind.ACCOUNT, - ) - - def _caller() -> Account: caller = Account(name="Uploader", email="uploader@example.com") caller.id = "account-1" @@ -68,11 +54,8 @@ def test_upload_uses_injected_file_service(app: Flask, monkeypatch: pytest.Monke data={"file": (BytesIO(b"hello"), "note.txt", "text/plain")}, content_type="multipart/form-data", ): - result = unwrap(AppFileUploadApi.post)( - AppFileUploadApi(), - app_id="app-1", - auth_data=_auth_data(caller), - ) + api = AppFileUploadApi() + result = api.post.__handler__(api, SimpleNamespace(caller=caller), app_id="app-1") assert result.id == "00000000-0000-0000-0000-000000000001" service.upload_file.assert_called_once_with( @@ -121,12 +104,9 @@ def test_upload_preserves_specific_file_errors( data={"file": (BytesIO(b"hello"), "note.txt", "text/plain")}, content_type="multipart/form-data", ): + api = AppFileUploadApi() with pytest.raises(controller_error) as error_info: - unwrap(AppFileUploadApi.post)( - AppFileUploadApi(), - app_id="app-1", - auth_data=_auth_data(_caller()), - ) + api.post.__handler__(api, SimpleNamespace(caller=_caller()), app_id="app-1") assert error_info.value.code == status assert error_info.value.error_code == error_code @@ -145,12 +125,9 @@ def test_upload_maps_other_value_errors_to_bad_request(app: Flask, monkeypatch: data={"file": (BytesIO(b"hello"), "../note.txt", "text/plain")}, content_type="multipart/form-data", ): + api = AppFileUploadApi() with pytest.raises(BadRequest) as error_info: - unwrap(AppFileUploadApi.post)( - AppFileUploadApi(), - app_id="app-1", - auth_data=_auth_data(_caller()), - ) + api.post.__handler__(api, SimpleNamespace(caller=_caller()), app_id="app-1") assert error_info.value.description == str(service_error) assert error_info.value.__cause__ is service_error diff --git a/api/tests/unit_tests/controllers/openapi/test_flask_admission.py b/api/tests/unit_tests/controllers/openapi/test_flask_admission.py deleted file mode 100644 index fb18eea75ae..00000000000 --- a/api/tests/unit_tests/controllers/openapi/test_flask_admission.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from functools import wraps -from uuid import UUID - -import pytest -from flask import Flask -from werkzeug.exceptions import Unauthorized - -from controllers.openapi import flask_admission -from controllers.openapi.auth.data import AuthData -from enums import DeploymentEdition -from libs.oauth_bearer import Scope, TokenType -from libs.rate_limit import LIMIT_ME_PER_ACCOUNT -from machinery.context import AccountRequestContext -from models.account import Account, AccountStatus - - -def _auth_data(*, status: AccountStatus = AccountStatus.ACTIVE) -> AuthData: - account = Account(name="Ada", email="ada@example.com", status=status) - account.id = "11111111-1111-1111-1111-111111111111" - return AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=UUID(account.id), - token_hash="hash-1", - token_id=UUID("22222222-2222-2222-2222-222222222222"), - scopes=frozenset({Scope.FULL}), - caller=account, - ) - - -def _install_fake_transport( - monkeypatch: pytest.MonkeyPatch, - auth_data: AuthData, - captured: dict[str, object], -) -> None: - def guard(**requirements: object) -> Callable[[Callable[..., object]], Callable[..., object]]: - captured.update(requirements) - - def decorator(view: Callable[..., object]) -> Callable[..., object]: - @wraps(view) - def admitted(*args: object, **kwargs: object) -> object: - return view(*args, auth_data=auth_data, **kwargs) - - return admitted - - return decorator - - monkeypatch.setattr(flask_admission.auth_router, "guard", guard) - - -def test_admission_builds_stable_request_context( - monkeypatch: pytest.MonkeyPatch, -) -> None: - captured: dict[str, object] = {} - limited: list[tuple[object, str]] = [] - _install_fake_transport(monkeypatch, _auth_data(), captured) - monkeypatch.setattr(flask_admission, "get_request_id", lambda: "request-1") - monkeypatch.setattr(flask_admission, "get_trace_id", lambda: "trace-1") - monkeypatch.setattr(flask_admission, "enforce", lambda spec, *, key: limited.append((spec, key))) - - @flask_admission.openapi_account_admission( - scope=Scope.FULL, - editions=frozenset({DeploymentEdition.ENTERPRISE}), - rate_limit=LIMIT_ME_PER_ACCOUNT, - ) - def view(_self: object, context: AccountRequestContext) -> AccountRequestContext: - return context - - with Flask(__name__).test_request_context("/openapi/v1/account"): - context = view(object()) - - assert context == AccountRequestContext( - request_id="request-1", - trace_id="trace-1", - account_id="11111111-1111-1111-1111-111111111111", - access_token_id="22222222-2222-2222-2222-222222222222", - ) - assert captured == { - "scope": Scope.FULL, - "allowed_token_types": frozenset({TokenType.OAUTH_ACCOUNT}), - "edition": frozenset({DeploymentEdition.ENTERPRISE}), - "require_valid_enterprise_license": True, - } - assert limited == [(LIMIT_ME_PER_ACCOUNT, "account:11111111-1111-1111-1111-111111111111")] - - -def test_admission_rejects_uninitialized_account(monkeypatch: pytest.MonkeyPatch) -> None: - _install_fake_transport(monkeypatch, _auth_data(status=AccountStatus.UNINITIALIZED), {}) - - @flask_admission.openapi_account_admission(scope=Scope.FULL) - def view(_self: object, _context: AccountRequestContext) -> None: - raise AssertionError("view must not run") - - with Flask(__name__).test_request_context("/openapi/v1/account"): - with pytest.raises(Unauthorized, match="account not initialized"): - view(object()) - - -def test_admission_rejects_missing_auth_data(monkeypatch: pytest.MonkeyPatch) -> None: - def guard(**_requirements: object) -> Callable[[Callable[..., object]], Callable[..., object]]: - def decorator(view: Callable[..., object]) -> Callable[..., object]: - return view - - return decorator - - monkeypatch.setattr(flask_admission.auth_router, "guard", guard) - - @flask_admission.openapi_account_admission(scope=Scope.FULL) - def view(_self: object, _context: AccountRequestContext) -> None: - raise AssertionError("view must not run") - - with Flask(__name__).test_request_context("/openapi/v1/account"): - with pytest.raises(RuntimeError, match="did not provide valid AuthData"): - view(object()) diff --git a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py index 97590ad6dfe..80bfae27870 100644 --- a/api/tests/unit_tests/controllers/openapi/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -1,4 +1,12 @@ -"""Tests for openapi human input form endpoints.""" +"""Tests for openapi human input form endpoints. + +Auth is not exercised here: `@endpoint` resolves the `Context` before the handler +runs, and the allow/deny answers live in `test_auth_matrix.py`. The recipient-surface +refusal is `CheckFormSurface`'s — a `Requirement` this feature owns rather than the +auth layer, so it is pinned here too. Body tests call `__handler__` — the one seam — +with a `Context` double. The submit route's 422 goes over the wire instead, +in the shared table in `test_workspaces_members.py`. +""" from __future__ import annotations @@ -7,34 +15,39 @@ import sys import uuid from datetime import UTC, datetime from types import SimpleNamespace +from typing import cast from unittest.mock import Mock import pytest from flask import Flask -from werkzeug.exceptions import UnprocessableEntity +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch -from controllers.openapi.auth.data import AuthData -from libs.oauth_bearer import Scope, TokenType +from controllers.openapi._models import FormSubmitResponse +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.requirements import Rank +from controllers.openapi.auth.subjects import Subject +from controllers.openapi.human_input_form import ( + CheckFormSurface, + OpenApiWorkflowHumanInputFormApi, + OpenApiWorkflowHumanInputFormSubmitApi, +) from models.account import Account -from models.enums import EndUserType +from models.enums import CreatorUserRole, EndUserType from models.human_input import RecipientType from models.model import App, AppMode, EndUser +_MODULE = "controllers.openapi.human_input_form" -def _make_auth_data(app_model, caller, caller_kind): - return AuthData.model_construct( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=uuid.uuid4(), - scopes=frozenset({Scope.FULL}), - app=app_model, - caller=caller, - caller_kind=caller_kind, + +def _context(caller: Account | EndUser, caller_role: CreatorUserRole) -> Context: + """A store already filled the way the route's requirements would have filled it.""" + ctx = Context( + cast(Subject, SimpleNamespace(caller_role=caller_role)), + Mock(), + {"app_id": "app-1", "form_token": "tok-1"}, ) - - -def _make_app() -> App: - return App( + ctx._app = App( id="app-1", tenant_id="tenant-1", name="Human input app", @@ -42,6 +55,27 @@ def _make_app() -> App: enable_site=True, enable_api=True, ) + ctx._caller = caller + return ctx + + +def _mock_service(monkeypatch: pytest.MonkeyPatch, form) -> Mock: + """Bind a mocked `HumanInputService` (and the engine it is built from) into the module.""" + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + module = sys.modules[_MODULE] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + return service_mock + + +def _make_form(app_id: str = "app-1", recipient_type=RecipientType.STANDALONE_WEB_APP) -> SimpleNamespace: + return SimpleNamespace( + app_id=app_id, + tenant_id="tenant-1", + recipient_type=recipient_type, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) def _make_account(account_id: str = "acct-1") -> Account: @@ -61,9 +95,7 @@ def _make_end_user(end_user_id: str = "eu-1") -> EndUser: class TestOpenApiHumanInputFormGet: - def test_get_success(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi - + def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch): definition = SimpleNamespace( model_dump=lambda: { "rendered_content": "Fill out the form", @@ -72,31 +104,17 @@ class TestOpenApiHumanInputFormGet: "user_actions": [{"id": "submit", "title": "Submit"}], } ) - form = SimpleNamespace( - app_id="app-1", - tenant_id="tenant-1", - recipient_type=RecipientType.STANDALONE_WEB_APP, - expiration_time=datetime(2099, 1, 1, tzinfo=UTC), - get_definition=lambda: definition, - ) - service_mock = Mock() - service_mock.get_form_by_token.return_value = form - service_mock.ensure_form_active = Mock() - - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + form = _make_form() + form.get_definition = lambda: definition + service_mock = _mock_service(monkeypatch, form) api = OpenApiWorkflowHumanInputFormApi() - app_model = _make_app() - caller = _make_account() - with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"): - resp = api.get.__wrapped__( + resp = api.get.__handler__( api, + _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "account"), ) payload = json.loads(resp.get_data(as_text=True)) @@ -105,119 +123,49 @@ class TestOpenApiHumanInputFormGet: assert payload["user_actions"] == [{"id": "submit", "title": "Submit"}] service_mock.ensure_form_active.assert_called_once_with(form) - def test_get_form_not_found(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi - - service_mock = Mock() - service_mock.get_form_by_token.return_value = None - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + def test_get_form_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + _mock_service(monkeypatch, None) api = OpenApiWorkflowHumanInputFormApi() - app_model = _make_app() - caller = _make_account() - with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/bad"): with pytest.raises(HumanInputFormNotFound): - api.get.__wrapped__( + api.get.__handler__( api, + _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", form_token="bad", - auth_data=_make_auth_data(app_model, caller, "account"), ) - def test_get_form_wrong_app(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi - - form = SimpleNamespace( - app_id="other-app", - tenant_id="tenant-1", - recipient_type=RecipientType.STANDALONE_WEB_APP, - expiration_time=datetime(2099, 1, 1, tzinfo=UTC), - ) - service_mock = Mock() - service_mock.get_form_by_token.return_value = form - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + def test_get_form_wrong_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + _mock_service(monkeypatch, _make_form(app_id="other-app")) api = OpenApiWorkflowHumanInputFormApi() - app_model = _make_app() - caller = _make_account() - with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"): with pytest.raises(HumanInputFormNotFound): - api.get.__wrapped__( + api.get.__handler__( api, + _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "account"), - ) - - def test_get_form_wrong_surface(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi - - form = SimpleNamespace( - app_id="app-1", - tenant_id="tenant-1", - recipient_type=RecipientType.CONSOLE, - expiration_time=datetime(2099, 1, 1, tzinfo=UTC), - ) - service_mock = Mock() - service_mock.get_form_by_token.return_value = form - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - - api = OpenApiWorkflowHumanInputFormApi() - app_model = _make_app() - caller = _make_account() - - with app.test_request_context("/openapi/v1/apps/app-1/human-input-forms/tok-1"): - with pytest.raises(RecipientSurfaceMismatch): - api.get.__wrapped__( - api, - app_id="app-1", - form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "account"), ) class TestOpenApiHumanInputFormPost: - def _make_form(self, app_id="app-1", recipient_type=RecipientType.STANDALONE_WEB_APP): - return SimpleNamespace( - app_id=app_id, - tenant_id="tenant-1", - recipient_type=recipient_type, - expiration_time=datetime(2099, 1, 1, tzinfo=UTC), - ) - - def test_post_account_caller_uses_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi - - form = self._make_form() - service_mock = Mock() - service_mock.get_form_by_token.return_value = form - - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + def test_post_account_caller_uses_user_id(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + service_mock = _mock_service(monkeypatch, _make_form()) api = OpenApiWorkflowHumanInputFormSubmitApi() - app_model = _make_app() - caller = _make_account("acct-42") - with app.test_request_context( "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST", json={"action": "approve", "inputs": {"field1": "val"}}, ): - result = api.post.__wrapped__( + result = api.post.__handler__( api, + _context(_make_account("acct-42"), CreatorUserRole.ACCOUNT), app_id="app-1", form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "account"), + body=HumanInputFormSubmitPayload(action="approve", inputs={"field1": "val"}), ) service_mock.submit_form_by_token.assert_called_once_with( @@ -228,33 +176,23 @@ class TestOpenApiHumanInputFormPost: submission_user_id="acct-42", submission_end_user_id=None, ) - assert result == ({}, 200) + assert result == FormSubmitResponse() - def test_post_end_user_caller_uses_end_user_id(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi - - form = self._make_form() - service_mock = Mock() - service_mock.get_form_by_token.return_value = form - - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + def test_post_end_user_caller_uses_end_user_id(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + service_mock = _mock_service(monkeypatch, _make_form()) api = OpenApiWorkflowHumanInputFormSubmitApi() - app_model = _make_app() - caller = _make_end_user("eu-7") - with app.test_request_context( "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", method="POST", json={"action": "approve", "inputs": {}}, ): - result = api.post.__wrapped__( + result = api.post.__handler__( api, + _context(_make_end_user("eu-7"), CreatorUserRole.END_USER), app_id="app-1", form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "end_user"), + body=HumanInputFormSubmitPayload(action="approve", inputs={}), ) service_mock.submit_form_by_token.assert_called_once_with( @@ -265,57 +203,48 @@ class TestOpenApiHumanInputFormPost: submission_user_id=None, submission_end_user_id="eu-7", ) - assert result == ({}, 200) + assert result == FormSubmitResponse() - def test_post_standalone_web_app_recipient_submits( - self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch + +@pytest.mark.parametrize( + "view", + [OpenApiWorkflowHumanInputFormApi.get, OpenApiWorkflowHumanInputFormSubmitApi.post], + ids=["get", "submit"], +) +def test_both_routes_declare_the_surface_check_last_at_the_default_rank(view): + """The wiring, not the requirement's own logic: `CheckFormSurface` only runs + on a route that declares it, and the allow/deny matrix has no console-recipient + case, so removing either declaration changes no row there. It carries no rank + of its own, so declaration order is what keeps it last. + """ + assert isinstance(view.__spec__.requirements[-1], CheckFormSurface) + assert CheckFormSurface.rank is Rank.NORMAL + + +class TestCheckFormSurface: + """The refusal itself. Declared last at both routes, so it answers before + the handler body runs. + """ + + def _run(self, monkeypatch: pytest.MonkeyPatch, form) -> None: + _mock_service(monkeypatch, form) + ctx = _context(_make_account(), CreatorUserRole.ACCOUNT) + CheckFormSurface().run(ctx.subject, ctx, Mock()) + + def test_admits_a_web_app_recipient(self, monkeypatch: pytest.MonkeyPatch): + self._run(monkeypatch, _make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)) + + @pytest.mark.parametrize("recipient_type", [RecipientType.CONSOLE, RecipientType.BACKSTAGE, None]) + def test_refuses_a_recipient_this_surface_may_not_act_on(self, monkeypatch: pytest.MonkeyPatch, recipient_type): + with pytest.raises(RecipientSurfaceMismatch): + self._run(monkeypatch, _make_form(recipient_type=recipient_type)) + + @pytest.mark.parametrize("form", [None, "foreign"]) + def test_leaves_a_form_the_caller_could_not_have_found_to_the_handlers_404( + self, monkeypatch: pytest.MonkeyPatch, form ): - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi - - form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP) - service_mock = Mock() - service_mock.get_form_by_token.return_value = form - - module = sys.modules["controllers.openapi.human_input_form"] - monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) - monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) - - api = OpenApiWorkflowHumanInputFormSubmitApi() - app_model = _make_app() - caller = _make_end_user("anyone") - - with app.test_request_context( - "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", - method="POST", - json={"action": "approve", "inputs": {}}, - ): - result = api.post.__wrapped__( - api, - app_id="app-1", - form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "end_user"), - ) - - service_mock.submit_form_by_token.assert_called_once() - assert result == ({}, 200) - - def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline): - """Malformed body → 422 via @accepts (was an unmapped pydantic error → 500).""" - from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormSubmitApi - - api = OpenApiWorkflowHumanInputFormSubmitApi() - app_model = _make_app() - caller = _make_account("acct-42") - - with app.test_request_context( - "/openapi/v1/apps/app-1/human-input-forms/tok-1:submit", - method="POST", - json={"inputs": {"field1": "val"}}, # missing required "action" - ): - with pytest.raises(UnprocessableEntity): - api.post.__wrapped__( - api, - app_id="app-1", - form_token="tok-1", - auth_data=_make_auth_data(app_model, caller, "account"), - ) + """Answering 403 on a console-bound form in another app would confirm the + token exists; the handler's own 404 owns both cases. + """ + foreign = _make_form(app_id=str(uuid.uuid4()), recipient_type=RecipientType.CONSOLE) + self._run(monkeypatch, foreign if form == "foreign" else None) diff --git a/api/tests/unit_tests/controllers/openapi/test_models.py b/api/tests/unit_tests/controllers/openapi/test_models.py index d29b592f6ab..9dd7e5b69fc 100644 --- a/api/tests/unit_tests/controllers/openapi/test_models.py +++ b/api/tests/unit_tests/controllers/openapi/test_models.py @@ -1,31 +1,6 @@ -from controllers.openapi._models import MessageMetadata, UsageInfo - - -def test_usage_info_defaults_zero(): - u = UsageInfo() - assert u.prompt_tokens == 0 - assert u.completion_tokens == 0 - assert u.total_tokens == 0 - - -def test_message_metadata_accepts_partial(): - m = MessageMetadata(usage=UsageInfo(total_tokens=10)) - assert m.usage.total_tokens == 10 - assert m.retriever_resources == [] +from controllers.openapi._models import AppDescribeResponse def test_describe_response_all_blocks_optional() -> None: - from controllers.openapi._models import AppDescribeResponse - payload = AppDescribeResponse().model_dump(mode="json", exclude_none=False) assert payload == {"info": None, "parameters": None, "input_schema": None} - - -def test_describe_response_input_schema_field() -> None: - from controllers.openapi._models import AppDescribeResponse - - schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object"} - payload = AppDescribeResponse(input_schema=schema).model_dump(mode="json", exclude_none=False) - assert payload["input_schema"] == schema - assert payload["info"] is None - assert payload["parameters"] is None diff --git a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py index 7f906826403..28039508b9d 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py +++ b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py @@ -2,39 +2,56 @@ The controller constructs a repository session factory, so every case binds that real SQLAlchemy factory to an isolated SQLite engine. Repository behavior -remains mocked because these tests focus on authorization and SSE responses. +remains mocked because these tests focus on run ownership and SSE responses. + +Auth is not exercised here: `@endpoint` resolves the `Context` before the handler +runs, and the allow/deny answers live in `test_auth_matrix.py`. """ from __future__ import annotations import sys -import uuid from types import SimpleNamespace from unittest.mock import Mock import pytest from flask import Flask -from sqlalchemy.engine import Engine -from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound -from controllers.openapi.auth.data import AuthData +from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi +from core.db.session_factory import session_factory from graphon.enums import WorkflowExecutionStatus -from libs.oauth_bearer import Scope, TokenType from models.account import Account from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.workflow import WorkflowRun, WorkflowType -def _make_auth_data(app_model, caller, caller_kind): - return AuthData.model_construct( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=uuid.uuid4(), - scopes=frozenset({Scope.FULL}), - app=app_model, +class _SealableContext: + """A `Context` stand-in that refuses reads once `seal()` is called. + + The router's session closes when the handler returns, so anything the SSE + body still needs off `ctx` would be read through a closed session. + """ + + def __init__(self, **values: object) -> None: + self._values = values + self._sealed = False + + def seal(self) -> None: + self._sealed = True + + def __getattr__(self, name: str) -> object: + if self._sealed: + raise AssertionError(f"ctx.{name} was read after the handler returned") + return self._values[name] + + +def _context(caller: Account | EndUser, caller_role: CreatorUserRole) -> _SealableContext: + return _SealableContext( + app=_make_app(), caller=caller, - caller_kind=caller_kind, + subject=SimpleNamespace(caller_role=caller_role), ) @@ -91,177 +108,95 @@ def _make_end_user() -> EndUser: class TestOpenApiWorkflowEventsApi: - @pytest.fixture(autouse=True) - def _bind_sqlite_engine(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> None: - module = sys.modules["controllers.openapi.workflow_events"] - monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine)) - - def _get_api(self): - from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi - - return OpenApiWorkflowEventsApi() - - def test_not_found_when_run_missing(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): + def _bind_repo(self, monkeypatch: pytest.MonkeyPatch, workflow_run: WorkflowRun | None) -> Mock: module = sys.modules["controllers.openapi.workflow_events"] repo_mock = Mock() - repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = None + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = workflow_run factory_mock = Mock() factory_mock.create_api_workflow_run_repository.return_value = repo_mock monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + return factory_mock - api = self._get_api() + def _bind_generators(self, monkeypatch: pytest.MonkeyPatch) -> None: + module = sys.modules["controllers.openapi.workflow_events"] + generator_mock = Mock() + generator_mock.convert_to_event_stream.return_value = iter([]) + monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) + msg_gen_mock = Mock() + msg_gen_mock.retrieve_events.return_value = iter([]) + monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) - app_model = _make_app() - caller = _make_account() + def test_not_found_when_run_missing(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + factory_mock = self._bind_repo(monkeypatch, None) + api = OpenApiWorkflowEventsApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): with pytest.raises(NotFound): - api.get.__wrapped__( + api.get.__handler__( api, + _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", task_id="wf-run-1", - auth_data=_make_auth_data(app_model, caller, "account"), ) + # The stream outlives the guard's session, so the repository gets the guard's + # own maker rather than a fresh one bound straight to the engine. session_maker = factory_mock.create_api_workflow_run_repository.call_args.args[0] - assert isinstance(session_maker, sessionmaker) - assert session_maker.kw["bind"] is module.db.engine + assert session_maker is session_factory.get_session_maker() - def test_not_found_when_run_belongs_to_different_app( - self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch - ): - module = sys.modules["controllers.openapi.workflow_events"] - run = _make_workflow_run(app_id="other-app") - repo_mock = Mock() - repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run - factory_mock = Mock() - factory_mock.create_api_workflow_run_repository.return_value = repo_mock - monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - - api = self._get_api() - - app_model = _make_app() - caller = _make_account() + def test_not_found_when_run_belongs_to_different_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + self._bind_repo(monkeypatch, _make_workflow_run(app_id="other-app")) + api = OpenApiWorkflowEventsApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): with pytest.raises(NotFound): - api.get.__wrapped__( + api.get.__handler__( api, + _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", task_id="wf-run-1", - auth_data=_make_auth_data(app_model, caller, "account"), ) - def test_account_caller_checks_created_by_account( - self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch - ): - """Account caller must match created_by == caller.id and role == ACCOUNT.""" - module = sys.modules["controllers.openapi.workflow_events"] - run = _make_workflow_run(created_by_role=CreatorUserRole.ACCOUNT, created_by="acct-1") - repo_mock = Mock() - repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run - factory_mock = Mock() - factory_mock.create_api_workflow_run_repository.return_value = repo_mock - monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + def test_account_caller_rejected_for_end_user_run(self, app: Flask, monkeypatch: pytest.MonkeyPatch): + self._bind_repo(monkeypatch, _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1")) - snapshot_builder = Mock(return_value=iter([])) - monkeypatch.setattr(module, "build_workflow_event_stream", snapshot_builder) - - generator_mock = Mock() - generator_mock.convert_to_event_stream.return_value = iter([]) - monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) - - msg_gen_mock = Mock() - msg_gen_mock.retrieve_events.return_value = iter([]) - monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) - - app_model = _make_app() - caller = _make_account() - - api = self._get_api() - with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): - # Should not raise NotFound for matching caller - resp = api.get.__wrapped__( - api, - app_id="app-1", - task_id="wf-run-1", - auth_data=_make_auth_data(app_model, caller, "account"), - ) - assert resp.mimetype == "text/event-stream" - - def test_account_caller_rejected_for_end_user_run( - self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch - ): - module = sys.modules["controllers.openapi.workflow_events"] - run = _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1") - repo_mock = Mock() - repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run - factory_mock = Mock() - factory_mock.create_api_workflow_run_repository.return_value = repo_mock - monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) - - app_model = _make_app() - caller = _make_account() - - api = self._get_api() + api = OpenApiWorkflowEventsApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): with pytest.raises(NotFound): - api.get.__wrapped__( + api.get.__handler__( api, + _context(_make_account(), CreatorUserRole.ACCOUNT), app_id="app-1", task_id="wf-run-1", - auth_data=_make_auth_data(app_model, caller, "account"), ) - def test_end_user_caller_checks_created_by_end_user( - self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch - ): + def test_end_user_caller_checks_created_by_end_user(self, app: Flask, monkeypatch: pytest.MonkeyPatch): """End-user caller must match created_by == caller.id and role == END_USER.""" - module = sys.modules["controllers.openapi.workflow_events"] - run = _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1") - repo_mock = Mock() - repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run - factory_mock = Mock() - factory_mock.create_api_workflow_run_repository.return_value = repo_mock - monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + self._bind_repo(monkeypatch, _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1")) + self._bind_generators(monkeypatch) - msg_gen_mock = Mock() - msg_gen_mock.retrieve_events.return_value = iter([]) - monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) - - generator_mock = Mock() - generator_mock.convert_to_event_stream.return_value = iter([]) - monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) - - app_model = _make_app() - caller = _make_end_user() - - api = self._get_api() + api = OpenApiWorkflowEventsApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): - resp = api.get.__wrapped__( + resp = api.get.__handler__( api, + _context(_make_end_user(), CreatorUserRole.END_USER), app_id="app-1", task_id="wf-run-1", - auth_data=_make_auth_data(app_model, caller, "end_user"), ) assert resp.mimetype == "text/event-stream" - def test_finished_run_returns_single_sse_event(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): - """A finished run returns a single done-event SSE response without streaming.""" + def _bind_finished_run(self, monkeypatch: pytest.MonkeyPatch) -> None: from datetime import UTC, datetime module = sys.modules["controllers.openapi.workflow_events"] - finished_at = datetime(2024, 1, 1, tzinfo=UTC) - run = _make_workflow_run( - created_by_role=CreatorUserRole.ACCOUNT, - created_by="acct-1", - finished_at=finished_at, + self._bind_repo( + monkeypatch, + _make_workflow_run( + created_by_role=CreatorUserRole.ACCOUNT, + created_by="acct-1", + finished_at=datetime(2024, 1, 1, tzinfo=UTC), + ), ) - repo_mock = Mock() - repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run - factory_mock = Mock() - factory_mock.create_api_workflow_run_repository.return_value = repo_mock - monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) finish_response = SimpleNamespace( event=SimpleNamespace(value="workflow_finished"), @@ -271,18 +206,54 @@ class TestOpenApiWorkflowEventsApi: converter_mock.workflow_run_result_to_finish_response.return_value = finish_response monkeypatch.setattr(module, "WorkflowResponseConverter", converter_mock) - app_model = _make_app() - caller = _make_account() + def test_finished_run_reads_everything_off_the_context_before_streaming( + self, app: Flask, monkeypatch: pytest.MonkeyPatch + ): + """A finished run answers a single done event without streaming. Its body + is a real generator, so it runs entirely after the handler has returned + and the router's session is gone. + """ + self._bind_finished_run(monkeypatch) - api = self._get_api() + ctx = _context(_make_account(), CreatorUserRole.ACCOUNT) + api = OpenApiWorkflowEventsApi() with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): - resp = api.get.__wrapped__( - api, - app_id="app-1", - task_id="wf-run-1", - auth_data=_make_auth_data(app_model, caller, "account"), - ) + resp = api.get.__handler__(api, ctx, app_id="app-1", task_id="wf-run-1") + ctx.seal() + body = "".join(resp.response) + assert resp.mimetype == "text/event-stream" - chunks = list(resp.response) - data = b"".join(c if isinstance(c, bytes) else c.encode() for c in chunks).decode() - assert "workflow_finished" in data + assert "workflow_finished" in body + + @pytest.mark.parametrize("include_state_snapshot", [False, True], ids=["events", "snapshot"]) + def test_stream_reads_everything_off_the_context_before_streaming( + self, app: Flask, monkeypatch: pytest.MonkeyPatch, include_state_snapshot: bool + ): + """Both stream shapes must be fully resolved before the handler returns, + and an account caller whose id and role match `created_by` is admitted. + + The snapshot branch is the one that used to read `app_model.tenant_id` and + `app_model.id` from inside the generator, which the router's closed session + can no longer answer. + """ + module = sys.modules["controllers.openapi.workflow_events"] + self._bind_repo(monkeypatch, _make_workflow_run(created_by_role=CreatorUserRole.ACCOUNT, created_by="acct-1")) + + generator_mock = Mock() + generator_mock.convert_to_event_stream.return_value = iter(["event: a\n\n", "event: b\n\n"]) + monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) + msg_gen_mock = Mock() + msg_gen_mock.retrieve_events.return_value = iter([]) + monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) + monkeypatch.setattr(module, "build_workflow_event_stream", Mock(return_value=iter([]))) + + ctx = _context(_make_account(), CreatorUserRole.ACCOUNT) + api = OpenApiWorkflowEventsApi() + query = "?include_state_snapshot=true" if include_state_snapshot else "" + with app.test_request_context(f"/openapi/v1/apps/app-1/tasks/wf-run-1/events{query}"): + resp = api.get.__handler__(api, ctx, app_id="app-1", task_id="wf-run-1") + ctx.seal() + body = "".join(resp.response) + + assert resp.mimetype == "text/event-stream" + assert body == "event: a\n\nevent: b\n\n" diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces.py b/api/tests/unit_tests/controllers/openapi/test_workspaces.py deleted file mode 100644 index 9cdc13a395a..00000000000 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Phase E step 17: workspace reads at /openapi/v1/workspaces. Bearer-authed -list + member-gated detail. No legacy /v1/ equivalent — the cookie-authed -/console/api/workspaces is a separate consumer that stays in console. -""" - -import builtins - -import pytest -from flask import Flask -from flask.views import MethodView - -from controllers.openapi import bp as openapi_bp -from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi - -if not hasattr(builtins, "MethodView"): - builtins.MethodView = MethodView # type: ignore[attr-defined] - - -@pytest.fixture -def openapi_app() -> Flask: - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(openapi_bp) - return app - - -def _rule(app: Flask, path: str): - return next(r for r in app.url_map.iter_rules() if r.rule == path) - - -def test_workspaces_list_route_registered(openapi_app: Flask): - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/workspaces" in rules - - -def test_workspaces_list_dispatches_to_workspaces_api(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces") - assert openapi_app.view_functions[rule.endpoint].view_class is WorkspacesApi - assert "GET" in rule.methods - - -def test_workspace_by_id_route_registered(openapi_app: Flask): - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/openapi/v1/workspaces/" in rules - - -def test_workspace_by_id_dispatches_to_correct_class(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces/") - assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceByIdApi - assert "GET" in rule.methods - - -def test_console_legacy_workspaces_route_not_remounted_on_openapi(openapi_app: Flask): - """Phase E only adds the bearer-authed mounts on /openapi/v1/. - The cookie-authed /console/api/workspaces stays where it is. - """ - rules = {r.rule for r in openapi_app.url_map.iter_rules()} - assert "/console/api/workspaces" not in rules diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index 9b998edff57..c13b7b2e0aa 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -1,51 +1,57 @@ """Member endpoints under /openapi/v1/workspaces//... Coverage: -- Route registration (5 endpoints across 3 URL patterns) -- Body validation lands at 400 (per spec — not Pydantic's default 422) +- Payload validation lands at 422 on the wire (unified via @accepts) — one + table for every `@accepts` route with a body or query model, the human-input + submit route included, since all of them share `admitted_bearer` - Domain exception → HTTP code mapping is preserved with the service's original message (so CLI users see what the console user sees) - Response shape matches the Pydantic models +- The invite route actually commits its unit of work -Auth-pipeline plumbing is bypassed via the `bypass_pipeline` fixture from -conftest.py; the bearer identity is seeded into the openapi auth ContextVar -via `_seed`. Tests that exercise -endpoint *bodies* skip the single `guard_workspace` decorator via -``__wrapped__`` — membership and role enforcement live in the auth pipeline -and are covered in `auth/test_prepare.py` and `auth/test_verify.py`. +Auth is not exercised here: `@endpoint` resolves the `Context` before the +handler runs, and the allow/deny answers live in `test_auth_matrix.py`. Body +tests call `__handler__` — the one seam — with a real `Context` over the test +database. The 422 tests cannot use it, because `@accepts` sits inside the guard +and `__handler__` is below it; they go over the wire through `admitted_bearer` +instead, and assert the canonical `ErrorBody` a client actually receives. """ from __future__ import annotations import builtins -import json import uuid from datetime import UTC, datetime +from http import HTTPMethod from types import SimpleNamespace from unittest.mock import Mock import pytest from flask import Flask from flask.views import MethodView -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from sqlalchemy import select -from sqlalchemy.orm import Session -from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.test import TestResponse from controllers.openapi import bp as openapi_bp from controllers.openapi import workspaces as workspaces_module -from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded -from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload -from controllers.openapi.auth.data import AuthData -from controllers.openapi.workspaces import ( - WorkspaceMemberApi, - WorkspaceMembersApi, - WorkspaceSwitchApi, +from controllers.openapi._errors import ( + ErrorBody, + MemberLicenseExceeded, + MemberLimitExceeded, + OpenApiErrorCode, ) +from controllers.openapi._models import MemberInvitePayload, MemberListQuery, MemberRoleUpdatePayload +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.loaders import load_caller, load_workspace +from controllers.openapi.auth.subjects import AccountSubject +from controllers.openapi.workspaces import WorkspaceMemberApi, WorkspaceMembersApi, WorkspaceSwitchApi from enums import DeploymentEdition -from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, reset_auth_ctx, set_auth_ctx +from libs.oauth_bearer import AuthContext, TokenType from models import Account, Tenant, TenantAccountJoin -from models.account import TenantAccountRole, TenantStatus +from models.account import TenantAccountRole from services.account_service import TenantService as RealTenantService from services.errors.account import ( AccountAlreadyInTenantError, @@ -57,6 +63,7 @@ from services.errors.account import ( RoleAlreadyAssignedError, ) from tests.unit_tests.config_override import config_overrides_context +from tests.unit_tests.controllers.openapi.conftest import AdmittedWorld from tests.unit_tests.model_factories import make_account, make_tenant if not hasattr(builtins, "MethodView"): @@ -67,23 +74,6 @@ if not hasattr(builtins, "MethodView"): # Fixtures # --------------------------------------------------------------------------- -# Tokens from `_seed`'s `set_auth_ctx` calls, drained after each test so a -# published identity can't leak into the next (the ContextVar is module-global -# and worker threads are reused). Seed via `_seed(...)`, never `flask.g` — -# production fills the ContextVar, nothing fills `g.auth_ctx`. -_seed_tokens: list = [] - - -def _seed(ctx: AuthContext) -> None: - _seed_tokens.append(set_auth_ctx(ctx)) - - -@pytest.fixture(autouse=True) -def _reset_auth_ctx(): - yield - while _seed_tokens: - reset_auth_ctx(_seed_tokens.pop()) - @pytest.fixture def openapi_app() -> Flask: @@ -98,41 +88,35 @@ def database_session(sqlite_session: Session): return sqlite_session -def _rule(app: Flask, path: str): - return next(r for r in app.url_map.iter_rules() if r.rule == path) - - def _auth_ctx(account_id: uuid.UUID | None = None) -> AuthContext: return AuthContext( - subject_type=SubjectType.ACCOUNT, subject_email="caller@example.com", subject_issuer="dify:account", account_id=account_id or uuid.uuid4(), client_id="difyctl", - scopes=frozenset({Scope.FULL}), token_id=uuid.uuid4(), token_type=TokenType.OAUTH_ACCOUNT, expires_at=datetime.now(UTC), ) -def _auth_data(account_id: uuid.UUID) -> AuthData: - from controllers.openapi.auth.data import AuthData - from libs.oauth_bearer import Scope, TokenType - - return AuthData( - token_type=TokenType.OAUTH_ACCOUNT, - account_id=account_id, - scopes=frozenset({Scope.FULL}), - ) +def _context(session: Session, account_id: uuid.UUID, workspace_id: str) -> Context: + """The context a handler is given *after* the pipeline ran: the caller's + subject, the request's session and its path params, plus the workspace and + caller these routes' requirements load off the same rows. + """ + ctx = Context(AccountSubject(_auth_ctx(account_id)), session, {"workspace_id": workspace_id}) + load_workspace(ctx) + load_caller(ctx) + return ctx def _account(account_id: str = "acct-1", email: str = "u@example.com") -> Account: return make_account(account_id=account_id, name="User", email=email) -def _tenant(tenant_id: str = "ws-1", *, status: TenantStatus = TenantStatus.NORMAL) -> Tenant: - tenant = make_tenant(tenant_id=tenant_id, name="WS", status=status) +def _tenant(tenant_id: str = "ws-1") -> Tenant: + tenant = make_tenant(tenant_id=tenant_id, name="WS") tenant.created_at = datetime(2026, 5, 18) return tenant @@ -141,10 +125,8 @@ def _persist_workspace( session: Session, workspace_id: str, memberships: list[tuple[str, str, TenantAccountRole, bool]], - *, - status: TenantStatus = TenantStatus.NORMAL, ) -> tuple[Tenant, list[Account]]: - tenant = _tenant(workspace_id, status=status) + tenant = _tenant(workspace_id) accounts: list[Account] = [] session.add(tenant) for account_id, email, role, current in memberships: @@ -161,6 +143,15 @@ def _persist_workspace( return tenant, accounts +def _persist_caller(session: Session, account_id: uuid.UUID) -> None: + """These routes all declare `CheckWorkspaceMember`, which resolves the + caller, so a handler reached through `__handler__` needs the caller's row + even when its own body only reads the workspace. + """ + session.add(_account(account_id=str(account_id), email="caller@example.com")) + session.commit() + + def _tenant_service(**overrides) -> SimpleNamespace: """Retain domain mutator doubles while delegating reads to the real service.""" methods: dict = { @@ -176,92 +167,73 @@ def _tenant_service(**overrides) -> SimpleNamespace: # --------------------------------------------------------------------------- -# Route registration +# Payload validation lands at 422 on the wire (unified via @accepts) # --------------------------------------------------------------------------- -def test_switch_route_registered(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces/:switch") - assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi - assert "POST" in rule.methods +def _assert_validation_422(resp: TestResponse) -> None: + """The wire contract for a rejected payload: 422 carrying the canonical body.""" + assert resp.status_code == 422, resp.get_json() + wire = resp.get_json() + ErrorBody.model_validate(wire) + assert wire["code"] == OpenApiErrorCode.INVALID_PARAM + assert wire["details"] -def test_members_route_registered(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces//members") - assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMembersApi - assert "GET" in rule.methods - assert "POST" in rule.methods - - -def test_member_by_id_route_registered(openapi_app: Flask): - rule = _rule(openapi_app, "/openapi/v1/workspaces//members/") - assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi - assert "DELETE" in rule.methods - assert "PATCH" in rule.methods - - -# --------------------------------------------------------------------------- -# Payload validation lands at 422 (unified via @accepts) -# --------------------------------------------------------------------------- - - -def test_invite_payload_rejects_unknown_role(): +@pytest.mark.parametrize( + ("model", "payload"), + [ + (MemberInvitePayload, {"email": "not-an-email", "role": TenantAccountRole.NORMAL}), + (MemberInvitePayload, {"email": "u@example.com", "role": TenantAccountRole.NORMAL, "extra": "x"}), + (MemberRoleUpdatePayload, {"role": TenantAccountRole.NORMAL, "extra": "x"}), + ], + ids=["invite.bad_email", "invite.extra_field", "role.extra_field"], +) +def test_payload_models_reject(model: type[BaseModel], payload: dict[str, object]): with pytest.raises(ValidationError): - MemberInvitePayload.model_validate({"email": "u@example.com", "role": "owner"}) + model.model_validate(payload) -def test_invite_payload_rejects_bad_email(): - with pytest.raises(ValidationError): - MemberInvitePayload.model_validate({"email": "not-an-email", "role": "normal"}) +@pytest.mark.parametrize( + ("method", "path", "body"), + [ + ( + HTTPMethod.POST, + "/openapi/v1/workspaces/{workspace_id}/members", + {"email": "u@example.com", "role": TenantAccountRole.OWNER}, + ), + ( + HTTPMethod.PATCH, + "/openapi/v1/workspaces/{workspace_id}/members/{member_id}", + {"role": TenantAccountRole.OWNER}, + ), + (HTTPMethod.GET, "/openapi/v1/workspaces/{workspace_id}/members?pg=2", None), + ( + HTTPMethod.POST, + "/openapi/v1/apps/{app_id}/human-input-forms/tok-1:submit", + {"inputs": {"field1": "val"}}, + ), + ], + ids=["invite.owner", "update_role.owner", "members_list.unknown_query", "human_input_form.missing_action"], +) +def test_invalid_request_is_422_on_the_wire( + admitted_bearer: AdmittedWorld, method: HTTPMethod, path: str, body: dict[str, object] | None +): + """Owner is not assignable, query models are `extra='forbid'`, and the form + submit needs an action: each surfaces as 422 from `@accepts` inside the guard. + """ + resp = admitted_bearer.client.open( + path.format( + workspace_id=admitted_bearer.workspace_id, + member_id=admitted_bearer.member_id, + app_id=admitted_bearer.app_id, + ), + method=method, + json=body, + headers=admitted_bearer.headers, + ) - -def test_invite_payload_rejects_extra_field(): - with pytest.raises(ValidationError): - MemberInvitePayload.model_validate({"email": "u@example.com", "role": "normal", "extra": "x"}) - - -def test_role_payload_rejects_owner(): - with pytest.raises(ValidationError): - MemberRoleUpdatePayload.model_validate({"role": "owner"}) - - -def test_role_payload_rejects_extra_field(): - with pytest.raises(ValidationError): - MemberRoleUpdatePayload.model_validate({"role": "normal", "extra": "x"}) - - -def test_invite_rejects_invalid_body_with_422(app: Flask, bypass_pipeline): - """Invalid invite body → 422 via @accepts (was 400 through _validate_body).""" - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceMembersApi() - - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members", - method="POST", - data=json.dumps({"email": "u@example.com", "role": "owner"}), # owner is not invite-assignable - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(UnprocessableEntity): - api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - - -def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline): - """Invalid role-update body surfaces as 422 through @accepts (was 400).""" - ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceMemberApi() - - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", - method="PATCH", - data=json.dumps({"role": "owner"}), # closed enum rejects owner - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(UnprocessableEntity): - api.patch.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)) + _assert_validation_422(resp) # --------------------------------------------------------------------------- @@ -269,7 +241,7 @@ def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline): # --------------------------------------------------------------------------- -def test_switch_returns_workspace_detail_with_current_true(app: Flask, bypass_pipeline, database_session: Session): +def test_switch_returns_workspace_detail_with_current_true(database_session: Session): """Happy path: switch service is called, then the workspace+membership row is re-queried so the returned `current` reflects post-commit state. """ @@ -283,13 +255,10 @@ def test_switch_returns_workspace_detail_with_current_true(app: Flask, bypass_pi [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, False)], ) - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + result = api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id) - assert status == 200 - assert body["id"] == ws_id - assert body["current"] is True + assert result.id == ws_id + assert result.current is True membership = database_session.scalar( select(TenantAccountJoin).where( TenantAccountJoin.tenant_id == ws_id, @@ -301,7 +270,7 @@ def test_switch_returns_workspace_detail_with_current_true(app: Flask, bypass_pi def test_switch_404s_when_service_raises_account_not_link_tenant( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session + monkeypatch: pytest.MonkeyPatch, database_session: Session ): """If switch_tenant raises (e.g. Tenant.status != NORMAL), the body surfaces as NotFound, not 500.""" @@ -321,10 +290,8 @@ def test_switch_404s_when_service_raises_account_not_link_tenant( _tenant_service(switch_tenant=Mock(side_effect=AccountNotLinkTenantError("…"))), ) - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}:switch", method="POST"): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(NotFound): - api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + with pytest.raises(NotFound): + api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id) # --------------------------------------------------------------------------- @@ -332,7 +299,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant( # --------------------------------------------------------------------------- -def test_members_list_returns_normalized_rows(app: Flask, bypass_pipeline, database_session: Session): +def test_members_list_returns_normalized_rows(database_session: Session): ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() member_id = str(uuid.uuid4()) @@ -344,23 +311,26 @@ def test_members_list_returns_normalized_rows(app: Flask, bypass_pipeline, datab [(member_id, "mia@example.com", TenantAccountRole.ADMIN, False)], ) members[0].name = "Mia" + _persist_caller(database_session, acct_id) - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + result = api.get.__handler__( + api, + _context(database_session, acct_id, ws_id), + workspace_id=ws_id, + query=MemberListQuery(), + ) - assert status == 200 - assert body["page"] == 1 - assert body["limit"] == 20 - assert body["total"] == 1 - assert body["has_more"] is False - assert body["data"][0]["email"] == "mia@example.com" - assert body["data"][0]["role"] == "admin" - assert body["data"][0]["status"] == "active" + assert result.page == 1 + assert result.limit == 20 + assert result.total == 1 + assert result.has_more is False + assert result.data[0].email == "mia@example.com" + assert result.data[0].role == "admin" + assert result.data[0].status == "active" -def test_members_list_paginates_with_query_params(app: Flask, bypass_pipeline, database_session: Session): - """`?page=2&limit=2` slices service output and reports total/has_more.""" +def test_members_list_paginates_with_query_params(database_session: Session): + """`page=2&limit=2` slices service output and reports total/has_more.""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -368,29 +338,20 @@ def test_members_list_paginates_with_query_params(app: Flask, bypass_pipeline, d member_ids = [str(uuid.uuid4()) for _ in range(5)] memberships = [(member_ids[i], f"u{i}@example.com", TenantAccountRole.NORMAL, False) for i in range(5)] _persist_workspace(database_session, ws_id, memberships) + _persist_caller(database_session, acct_id) - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?page=2&limit=2"): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + result = api.get.__handler__( + api, + _context(database_session, acct_id, ws_id), + workspace_id=ws_id, + query=MemberListQuery(page=2, limit=2), + ) - assert status == 200 - assert body["page"] == 2 - assert body["limit"] == 2 - assert body["total"] == 5 - assert body["has_more"] is True - assert [d["id"] for d in body["data"]] == member_ids[2:4] - - -def test_members_list_rejects_unknown_query_param(app: Flask, bypass_pipeline): - """Strict (`extra='forbid'`) — typos like `?pg=2` surface as 422 (unified via @accepts).""" - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceMembersApi() - - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?pg=2"): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(UnprocessableEntity): - api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + assert result.page == 2 + assert result.limit == 2 + assert result.total == 5 + assert result.has_more is True + assert [d.id for d in result.data] == member_ids[2:4] # --------------------------------------------------------------------------- @@ -398,9 +359,11 @@ def test_members_list_rejects_unknown_query_param(app: Flask, bypass_pipeline): # --------------------------------------------------------------------------- -def test_invite_happy_path_returns_invite_url_and_member_id( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): +def _invite_body(email: str = "new@example.com") -> MemberInvitePayload: + return MemberInvitePayload(email=email, role="normal") + + +def test_invite_happy_path_returns_invite_url_and_member_id(monkeypatch: pytest.MonkeyPatch, database_session: Session): ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -420,23 +383,71 @@ def test_invite_happy_path_returns_invite_url_and_member_id( SimpleNamespace(invite_new_member=Mock(return_value="tok-123")), ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members", - method="POST", - data=json.dumps({"email": "NEW@example.com", "role": "normal"}), - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + result = api.post.__handler__( + api, + _context(database_session, acct_id, ws_id), + workspace_id=ws_id, + body=_invite_body("NEW@example.com"), + ) - assert status == 201 - assert body["result"] == "success" - assert body["email"] == "new@example.com" - assert body["role"] == "normal" - assert body["member_id"] == invited_id - assert "token=tok-123" in body["invite_url"] - assert "email=new%40example.com" in body["invite_url"] - assert body["tenant_id"] == ws_id + assert result.result == "success" + assert result.email == "new@example.com" + assert result.role == "normal" + assert result.member_id == invited_id + assert "token=tok-123" in result.invite_url + assert "email=new%40example.com" in result.invite_url + assert result.tenant_id == ws_id + + +def test_invite_commits_the_invitation( + openapi_app: Flask, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, +): + """`RegisterService.invite_new_member` carries no commit of its own, so the + invitation persists only because the router commits the request's session. + The double stands in for that contract — it writes through the session it + is handed and returns a token — so this fails if the router stops committing. + """ + ws_id = str(uuid.uuid4()) + acct_id = uuid.uuid4() + invited_id = str(uuid.uuid4()) + + with sqlite_session_factory() as setup: + _persist_workspace( + setup, + ws_id, + [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)], + ) + setup.add(_account(account_id=invited_id, email="new@example.com")) + setup.commit() + + def _invite(*, tenant, email, language, role, inviter, session) -> str: + session.add(TenantAccountJoin(tenant_id=tenant.id, account_id=invited_id, role=TenantAccountRole.NORMAL)) + return "tok-persist" + + monkeypatch.setattr(workspaces_module, "RegisterService", SimpleNamespace(invite_new_member=_invite)) + monkeypatch.setattr( + "controllers.openapi.auth.router.get_authenticator", + lambda: SimpleNamespace(authenticate=lambda _token: _auth_ctx(acct_id)), + ) + monkeypatch.setattr("controllers.openapi.auth.pipelines._mount_flask_login", lambda _user: None) + + response = openapi_app.test_client().post( + f"/openapi/v1/workspaces/{ws_id}/members", + json={"email": "new@example.com", "role": "normal"}, + headers={"Authorization": "Bearer dfoa_matrix"}, + ) + + assert response.status_code == 201, response.get_json() + with sqlite_session_factory() as verify: + persisted = verify.scalar( + select(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == ws_id, + TenantAccountJoin.account_id == invited_id, + ) + ) + assert persisted is not None def _features( @@ -468,19 +479,8 @@ def _features( ) -def _invite_request(app, ws_id: str, acct_id: uuid.UUID): - return app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members", - method="POST", - data=json.dumps({"email": "new@example.com", "role": "normal"}), - content_type="application/json", - ) - - @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) -def test_invite_blocked_by_saas_members_cap( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): +def test_invite_blocked_by_saas_members_cap(monkeypatch: pytest.MonkeyPatch, database_session: Session): """SaaS billing plan member cap → MemberLimitExceeded (403).""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -508,18 +508,14 @@ def test_invite_blocked_by_saas_members_cap( ), ) - with _invite_request(app, ws_id, acct_id): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(MemberLimitExceeded): - api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + with pytest.raises(MemberLimitExceeded): + api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, body=_invite_body()) invite_mock.assert_not_called() @config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) -def test_invite_blocked_by_ee_workspace_members_license( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): +def test_invite_blocked_by_ee_workspace_members_license(monkeypatch: pytest.MonkeyPatch, database_session: Session): """EE License workspace_members cap → MemberLicenseExceeded (403). Enterprise member limits come from the license. @@ -554,54 +550,21 @@ def test_invite_blocked_by_ee_workspace_members_license( ), ) - with _invite_request(app, ws_id, acct_id): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(MemberLicenseExceeded): - api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + with pytest.raises(MemberLicenseExceeded): + api.post.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, body=_invite_body()) invite_mock.assert_not_called() -def test_invite_ce_passes_when_both_caps_disabled( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): - """CE deployment (no billing, no license) → quota gate is a no-op, - invite proceeds normally.""" - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceMembersApi() - - invited_id = str(uuid.uuid4()) - _persist_workspace( - database_session, - ws_id, - [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)], - ) - database_session.add(_account(account_id=invited_id, email="new@example.com")) - database_session.commit() - - monkeypatch.setattr( - workspaces_module, - "RegisterService", - SimpleNamespace(invite_new_member=Mock(return_value="tok-ce")), - ) - monkeypatch.setattr( - workspaces_module, - "FeatureService", - SimpleNamespace(get_features=Mock(return_value=_features())), # all defaults - ) - - with _invite_request(app, ws_id, acct_id): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - - assert status == 201 - assert body["email"] == "new@example.com" - - -def test_invite_400_when_already_in_tenant( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): +@pytest.mark.parametrize( + "exc", + [ + AccountAlreadyInTenantError("already in tenant"), + AccountRegisterError("Workspace is not allowed to create."), + ], + ids=["already_in_tenant", "register_error"], +) +def test_invite_400_on_registration_refusal(monkeypatch: pytest.MonkeyPatch, database_session: Session, exc: Exception): ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -615,18 +578,16 @@ def test_invite_400_when_already_in_tenant( monkeypatch.setattr( workspaces_module, "RegisterService", - SimpleNamespace(invite_new_member=Mock(side_effect=AccountAlreadyInTenantError("already in tenant"))), + SimpleNamespace(invite_new_member=Mock(side_effect=exc)), ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members", - method="POST", - data=json.dumps({"email": "u@example.com", "role": "normal"}), - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(BadRequest): - api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + with pytest.raises(BadRequest): + api.post.__handler__( + api, + _context(database_session, acct_id, ws_id), + workspace_id=ws_id, + body=_invite_body(), + ) # --------------------------------------------------------------------------- @@ -634,9 +595,7 @@ def test_invite_400_when_already_in_tenant( # --------------------------------------------------------------------------- -def test_delete_member_happy_path( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): +def test_delete_member_happy_path(monkeypatch: pytest.MonkeyPatch, database_session: Session): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -657,17 +616,11 @@ def test_delete_member_happy_path( _tenant_service(remove_member_from_tenant=remove_mock), ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", - method="DELETE", - ): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.delete.__wrapped__( - api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id) - ) + result = api.delete.__handler__( + api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, member_id=member_id + ) - assert status == 200 - assert body == {"result": "success"} + assert result.result == "success" assert remove_mock.called @@ -679,9 +632,7 @@ def test_delete_member_happy_path( (MemberNotInTenantError("not in tenant"), NotFound), ], ) -def test_delete_member_exception_mapping( - app: Flask, bypass_pipeline, monkeypatch, exc, expected, database_session: Session -): +def test_delete_member_exception_mapping(monkeypatch, exc, expected, database_session: Session): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -701,21 +652,11 @@ def test_delete_member_exception_mapping( _tenant_service(remove_member_from_tenant=Mock(side_effect=exc)), ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", - method="DELETE", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(expected): - api.delete.__wrapped__( - api, - workspace_id=ws_id, - member_id=member_id, - auth_data=_auth_data(acct_id), - ) + with pytest.raises(expected): + api.delete.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, member_id=member_id) -def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, database_session: Session): +def test_delete_member_404_when_member_missing(database_session: Session): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -726,18 +667,8 @@ def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, data [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)], ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", - method="DELETE", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(NotFound): - api.delete.__wrapped__( - api, - workspace_id=ws_id, - member_id=member_id, - auth_data=_auth_data(acct_id), - ) + with pytest.raises(NotFound): + api.delete.__handler__(api, _context(database_session, acct_id, ws_id), workspace_id=ws_id, member_id=member_id) # --------------------------------------------------------------------------- @@ -745,9 +676,7 @@ def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, data # --------------------------------------------------------------------------- -def test_update_role_happy_path( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): +def test_update_role_happy_path(monkeypatch: pytest.MonkeyPatch, database_session: Session): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -768,19 +697,15 @@ def test_update_role_happy_path( _tenant_service(update_member_role=update_mock), ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", - method="PATCH", - data=json.dumps({"role": "admin"}), - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - body, status = api.patch.__wrapped__( - api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id) - ) + result = api.patch.__handler__( + api, + _context(database_session, acct_id, ws_id), + workspace_id=ws_id, + member_id=member_id, + body=MemberRoleUpdatePayload(role="admin"), + ) - assert status == 200 - assert body == {"result": "success"} + assert result.result == "success" args = update_mock.call_args.args assert args[2] == "admin" @@ -794,9 +719,7 @@ def test_update_role_happy_path( (MemberNotInTenantError("not in tenant"), NotFound), ], ) -def test_update_role_exception_mapping( - app: Flask, bypass_pipeline, monkeypatch, exc, expected, database_session: Session -): +def test_update_role_exception_mapping(monkeypatch, exc, expected, database_session: Session): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -816,74 +739,11 @@ def test_update_role_exception_mapping( _tenant_service(update_member_role=Mock(side_effect=exc)), ) - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members/{member_id}", - method="PATCH", - data=json.dumps({"role": "admin"}), - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(expected): - api.patch.__wrapped__( - api, - workspace_id=ws_id, - member_id=member_id, - auth_data=_auth_data(acct_id), - ) - - -# --------------------------------------------------------------------------- -# _load_tenant rejects archived tenant -# --------------------------------------------------------------------------- - - -def test_load_tenant_rejects_archived_workspace(app: Flask, bypass_pipeline, database_session: Session): - """Member management against an archived workspace → 404.""" - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceMembersApi() - - _persist_workspace(database_session, ws_id, [], status=TenantStatus.ARCHIVE) - - with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(NotFound): - api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) - - -# --------------------------------------------------------------------------- -# Invite catches AccountRegisterError -# --------------------------------------------------------------------------- - - -def test_invite_400_when_register_error( - app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch, database_session: Session -): - """AccountRegisterError (frozen email, workspace creation blocked) → 400.""" - ws_id = str(uuid.uuid4()) - acct_id = uuid.uuid4() - api = WorkspaceMembersApi() - - _persist_workspace( - database_session, - ws_id, - [(str(acct_id), "caller@example.com", TenantAccountRole.OWNER, True)], - ) - - monkeypatch.setattr( - workspaces_module, - "RegisterService", - SimpleNamespace( - invite_new_member=Mock(side_effect=AccountRegisterError("Workspace is not allowed to create.")), - ), - ) - - with app.test_request_context( - f"/openapi/v1/workspaces/{ws_id}/members", - method="POST", - data=json.dumps({"email": "frozen@example.com", "role": "normal"}), - content_type="application/json", - ): - _seed(_auth_ctx(account_id=acct_id)) - with pytest.raises(BadRequest): - api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) + with pytest.raises(expected): + api.patch.__handler__( + api, + _context(database_session, acct_id, ws_id), + workspace_id=ws_id, + member_id=member_id, + body=MemberRoleUpdatePayload(role="admin"), + ) diff --git a/api/tests/unit_tests/libs/test_oauth_bearer.py b/api/tests/unit_tests/libs/test_oauth_bearer.py index 1ce25a48f73..e6566adcb9a 100644 --- a/api/tests/unit_tests/libs/test_oauth_bearer.py +++ b/api/tests/unit_tests/libs/test_oauth_bearer.py @@ -1,29 +1,50 @@ -"""Unit tests for the openapi bearer-scope catalog and TokenKind registry.""" +"""The bearer catalog: which subject each token type serves and what it may do.""" from __future__ import annotations +import uuid +from datetime import UTC, datetime from unittest.mock import MagicMock +import pytest -def test_apps_read_permitted_external_scope_present(): - from libs.oauth_bearer import Scope - - assert Scope.APPS_READ_PERMITTED_EXTERNAL.value == "apps:read:permitted-external" +from libs.oauth_bearer import ResolvedRow, Scope, SubjectType, TokenType, _TokenTypeResolver -def test_dfoe_token_kind_carries_apps_read_permitted_external(): - from libs.oauth_bearer import Scope, build_registry - - registry = build_registry(MagicMock(), MagicMock()) - dfoe = next(k for k in registry.kinds() if k.prefix == "dfoe_") - assert Scope.APPS_READ_PERMITTED_EXTERNAL in dfoe.scopes +def _row(account_id: uuid.UUID | None) -> ResolvedRow: + return ResolvedRow( + subject_email="who@example.com", + subject_issuer="issuer", + account_id=account_id, + client_id="client", + token_id=uuid.uuid4(), + expires_at=datetime.now(UTC), + ) -def test_dfoa_token_kind_does_not_carry_apps_read_permitted_external(): - """dfoa_ relies on Scope.FULL umbrella; the explicit permitted scope - is reserved for dfoe_.""" - from libs.oauth_bearer import Scope, build_registry +@pytest.mark.parametrize("token_type", list(TokenType), ids=lambda t: t.value) +def test_a_row_matches_its_subject_only_when_its_account_binding_agrees(token_type: TokenType) -> None: + """Every token type, present or future, is held to the binding its subject declares.""" + resolver = _TokenTypeResolver(MagicMock(), token_type) + bound = token_type.subject.bound_to_account + assert resolver._matches_subject(_row(uuid.uuid4())) is bound + assert resolver._matches_subject(_row(None)) is not bound - registry = build_registry(MagicMock(), MagicMock()) - dfoa = next(k for k in registry.kinds() if k.prefix == "dfoa_") - assert Scope.APPS_READ_PERMITTED_EXTERNAL not in dfoa.scopes + +def test_scope_sets_are_pinned_exactly() -> None: + """`dfoa_` relies on the `Scope.FULL` umbrella; the explicit scopes are reserved for `dfoe_`.""" + assert SubjectType.ACCOUNT.scopes == frozenset({Scope.FULL}) + assert SubjectType.EXTERNAL_SSO.scopes == frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}) + + +@pytest.mark.parametrize( + ("token", "expected"), + [ + ("dfoa_abc", TokenType.OAUTH_ACCOUNT), + ("dfoe_abc", TokenType.OAUTH_EXTERNAL_SSO), + ("dfp_abc", None), + ("", None), + ], +) +def test_for_token_classifies_by_prefix_and_refuses_the_rest(token: str, expected: TokenType | None) -> None: + assert TokenType.for_token(token) is expected diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py b/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py index d3d2d583f25..845e520d946 100644 --- a/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py +++ b/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py @@ -4,42 +4,11 @@ from unittest.mock import MagicMock, patch import pytest -from libs.oauth_bearer import ( - BearerAuthenticator, - InvalidBearerError, - Scope, - SubjectType, - TokenKind, - TokenKindRegistry, - TokenType, -) +from libs.oauth_bearer import BearerAuthenticator, InvalidBearerError, TokenType -def _registry_with_resolver(resolver) -> TokenKindRegistry: - return TokenKindRegistry( - [ - TokenKind( - prefix="dfoa_", - subject_type=SubjectType.ACCOUNT, - scopes=frozenset({Scope.FULL}), - token_type=TokenType.OAUTH_ACCOUNT, - resolver=resolver, - ) - ] - ) - - -@patch("libs.oauth_bearer.enforce_bearer_rate_limit") -def test_rate_limit_called_on_unknown_revoked_token(rl): - resolver = MagicMock() - resolver.resolve.return_value = None - auth = BearerAuthenticator(_registry_with_resolver(resolver)) - - with pytest.raises(InvalidBearerError): - auth.authenticate("dfoa_revokedtoken123") - - rl.assert_called_once() - resolver.resolve.assert_called_once() +def _authenticator_with(resolver) -> BearerAuthenticator: + return BearerAuthenticator({TokenType.OAUTH_ACCOUNT: resolver}) @patch("libs.oauth_bearer.enforce_bearer_rate_limit") @@ -48,7 +17,7 @@ def test_rate_limit_called_before_resolve(rl): rl.side_effect = lambda _h: call_order.append("rl") resolver = MagicMock() resolver.resolve.side_effect = lambda _h: call_order.append("resolve") or None - auth = BearerAuthenticator(_registry_with_resolver(resolver)) + auth = _authenticator_with(resolver) with pytest.raises(InvalidBearerError): auth.authenticate("dfoa_xyz") @@ -56,30 +25,12 @@ def test_rate_limit_called_before_resolve(rl): assert call_order == ["rl", "resolve"], f"expected rl before resolve, got {call_order}" -def test_unknown_prefix_raises_generic_invalid_bearer(): - auth = BearerAuthenticator( - TokenKindRegistry( - [ - TokenKind( - prefix="dfoa_", - subject_type=SubjectType.ACCOUNT, - scopes=frozenset({Scope.FULL}), - token_type=TokenType.OAUTH_ACCOUNT, - resolver=MagicMock(), - ) - ] - ) - ) - with pytest.raises(InvalidBearerError) as exc: - auth.authenticate("zzz_xyz") - assert str(exc.value) == "invalid_bearer" - - +@pytest.mark.parametrize("token", ["zzz_xyz", "dfoa_revoked"], ids=["unknown prefix", "revoked"]) @patch("libs.oauth_bearer.enforce_bearer_rate_limit") -def test_revoked_token_raises_generic_invalid_bearer(rl): +def test_every_refusal_raises_the_generic_invalid_bearer(rl, token: str): resolver = MagicMock() resolver.resolve.return_value = None - auth = BearerAuthenticator(_registry_with_resolver(resolver)) + auth = _authenticator_with(resolver) with pytest.raises(InvalidBearerError) as exc: - auth.authenticate("dfoa_revoked") + auth.authenticate(token) assert str(exc.value) == "invalid_bearer" diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 651f39a79f3..18d3252ef04 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -148,13 +148,6 @@ project-excludes = [ "controllers/inner_api/test_runtime_credentials.py", "controllers/inner_api/workspace/test_workspace.py", "controllers/mcp/test_mcp.py", - "controllers/openapi/auth/test_composition.py", - "controllers/openapi/auth/test_conditions.py", - "controllers/openapi/auth/test_data.py", - "controllers/openapi/auth/test_flow.py", - "controllers/openapi/auth/test_pipeline.py", - "controllers/openapi/auth/test_prepare.py", - "controllers/openapi/auth/test_verify.py", "controllers/openapi/conftest.py", "controllers/openapi/test_account.py", "controllers/openapi/test_app_describe_builder.py", @@ -181,7 +174,6 @@ project-excludes = [ "controllers/openapi/test_oauth_sso_claims.py", "controllers/openapi/test_oauth_sso_csrf.py", "controllers/openapi/test_oauth_sso_host_header.py", - "controllers/openapi/test_pagination_envelope.py", "controllers/openapi/test_supported_app_type.py", "controllers/openapi/test_version_gate.py", "controllers/openapi/test_workflow_events_openapi.py", @@ -694,9 +686,7 @@ project-excludes = [ "libs/test_login.py", "libs/test_oauth_base.py", "libs/test_oauth_bearer.py", - "libs/test_oauth_bearer_layer0_cache.py", "libs/test_oauth_bearer_rate_limit_ordering.py", - "libs/test_oauth_bearer_require_scope.py", "libs/test_oauth_clients.py", "libs/test_orjson.py", "libs/test_pagination.py", @@ -713,7 +703,6 @@ project-excludes = [ "libs/test_token.py", "libs/test_token_manager.py", "libs/test_uuid_utils.py", - "libs/test_workspace_member_helper.py", "libs/test_workspace_permission.py", "libs/test_yarl.py", "migrations/test_uuidv7_pg18_migration.py", @@ -780,7 +769,6 @@ project-excludes = [ "services/enterprise/test_rbac_service.py", "services/enterprise/test_traceparent_propagation.py", "services/hit_service.py", - "services/openapi/test_mint_policy.py", "services/plugin/conftest.py", "services/plugin/test_dependencies_analysis.py", "services/plugin/test_endpoint_service.py", diff --git a/api/tests/unit_tests/services/openapi/__init__.py b/api/tests/unit_tests/services/openapi/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/tests/unit_tests/services/openapi/test_mint_policy.py b/api/tests/unit_tests/services/openapi/test_mint_policy.py deleted file mode 100644 index c3df657ed4c..00000000000 --- a/api/tests/unit_tests/services/openapi/test_mint_policy.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Tests for the mint-policy validator. - -Cross-checks the (subject_type, prefix, scopes) triple a caller intends -to mint against ``MINTABLE_PROFILES``. The validator's defense-in-depth -value kicks in when a caller wires scopes or prefix from a non-canonical -source — the well-formed canonical path is the no-violation case. -""" - -from __future__ import annotations - -import pytest - -from libs.oauth_bearer import MINTABLE_PROFILES, Scope, SubjectType -from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy - - -def test_canonical_account_profile_passes(): - profile = MINTABLE_PROFILES[SubjectType.ACCOUNT] - validate_mint_policy( - subject_type=profile.subject_type, - prefix=profile.prefix, - scopes=profile.scopes, - ) - - -def test_canonical_external_sso_profile_passes(): - profile = MINTABLE_PROFILES[SubjectType.EXTERNAL_SSO] - validate_mint_policy( - subject_type=profile.subject_type, - prefix=profile.prefix, - scopes=profile.scopes, - ) - - -def test_wrong_prefix_rejected(): - with pytest.raises(MintPolicyViolation) as exc: - validate_mint_policy( - subject_type=SubjectType.ACCOUNT, - prefix="dfoe_", # SSO prefix on an account subject - scopes=frozenset({Scope.FULL}), - ) - assert "prefix" in str(exc.value) - - -def test_wrong_scopes_rejected(): - with pytest.raises(MintPolicyViolation) as exc: - validate_mint_policy( - subject_type=SubjectType.ACCOUNT, - prefix="dfoa_", - scopes=frozenset({Scope.APPS_RUN}), # account should be {FULL} - ) - assert "scopes" in str(exc.value) - - -def test_external_sso_with_full_scope_rejected(): - with pytest.raises(MintPolicyViolation): - validate_mint_policy( - subject_type=SubjectType.EXTERNAL_SSO, - prefix="dfoe_", - scopes=frozenset({Scope.FULL}), # FULL never applies to dfoe_ - ) - - -def test_message_carries_both_drift_reasons(): - """Mismatched prefix AND mismatched scopes both surface in one error.""" - with pytest.raises(MintPolicyViolation) as exc: - validate_mint_policy( - subject_type=SubjectType.ACCOUNT, - prefix="dfoe_", - scopes=frozenset({Scope.APPS_RUN}), - ) - msg = str(exc.value) - assert "prefix" in msg - assert "scopes" in msg diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index d568fb6742c..7ab02093684 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -373,31 +373,6 @@ class TestOpenapiVisibilityHelpers: with patch("services.app_service.is_openapi_visible", return_value=False): assert AppService.get_visible_app_by_id(app.id, sqlite_session) is None - def test_find_visible_apps_by_name_returns_scalars_through_visibility_gate(self, sqlite_session: Session): - """Tenant-scoped name lookup. The helper passes the SELECT through - ``apply_openapi_gate`` and materialises ``.scalars()`` into a list - so the controller can branch on length (404 / single / 409). - """ - tenant_id = str(uuid4()) - rows = [ - _persist_app(sqlite_session, tenant_id=tenant_id, name="my-app"), - _persist_app(sqlite_session, tenant_id=tenant_id, name="my-app"), - ] - _persist_app(sqlite_session, tenant_id=str(uuid4()), name="my-app") - - with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q) as gate: - out = AppService.find_visible_apps_by_name(name="my-app", tenant_id=tenant_id, session=sqlite_session) - - assert {app.id for app in out} == {app.id for app in rows} - # Visibility gate must wrap the SELECT exactly once. - gate.assert_called_once() - - def test_find_visible_apps_by_name_returns_empty_list_on_no_match(self, sqlite_session: Session): - with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q): - out = AppService.find_visible_apps_by_name(name="nope", tenant_id=str(uuid4()), session=sqlite_session) - - assert out == [] - def test_find_visible_apps_by_ids_short_circuits_on_empty_input(self, unbound_session: Session): """Empty id list must not emit ``WHERE id IN ()`` — Postgres rejects empty IN lists and the call is a guaranteed no-op diff --git a/api/tests/unit_tests/services/test_oauth_device_flow_mint.py b/api/tests/unit_tests/services/test_oauth_device_flow_mint.py new file mode 100644 index 00000000000..f16493b7774 --- /dev/null +++ b/api/tests/unit_tests/services/test_oauth_device_flow_mint.py @@ -0,0 +1,44 @@ +"""Minting normalises the issuer by the subject's declared account binding.""" + +from __future__ import annotations + +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +from libs.oauth_bearer import TokenType +from services.oauth_device_flow import ACCOUNT_ISSUER_SENTINEL, UpsertOutcome, mint_oauth_token + +UPSERT = "services.oauth_device_flow._upsert" + + +def _mint(token_type: TokenType, subject_issuer: str | None) -> MagicMock: + outcome = UpsertOutcome(token_id=uuid.uuid4(), rotated=False, old_hash=None) + with patch(UPSERT, return_value=outcome) as upsert: + mint_oauth_token( + MagicMock(), + subject_email="who@example.com", + subject_issuer=subject_issuer, + account_id=str(uuid.uuid4()) if token_type.subject.bound_to_account else None, + client_id="client", + device_label="device", + token_type=token_type, + ttl_days=1, + session=MagicMock(), + ) + return upsert + + +@pytest.mark.parametrize("token_type", list(TokenType), ids=lambda t: t.value) +def test_the_issuer_follows_the_subject_binding(token_type: TokenType) -> None: + if token_type.subject.bound_to_account: + upsert = _mint(token_type, None) + assert upsert.call_args.kwargs["subject_issuer"] == ACCOUNT_ISSUER_SENTINEL + with pytest.raises(ValueError, match="ACCOUNT_ISSUER_SENTINEL"): + _mint(token_type, "https://idp.example.com") + else: + upsert = _mint(token_type, "https://idp.example.com") + assert upsert.call_args.kwargs["subject_issuer"] == "https://idp.example.com" + with pytest.raises(ValueError, match="subject_issuer"): + _mint(token_type, " ") diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index ac9af4af710..7aa67a61c26 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -15,7 +15,7 @@ export type AccountResponse = { default_workspace_id?: string | null subject_email?: string | null subject_issuer?: string | null - subject_type: string + subject_type: SubjectType workspaces?: Array } @@ -169,7 +169,7 @@ export type DeviceTokenResponse = { expires_at: string subject_email?: string | null subject_issuer?: string | null - subject_type: 'account' | 'external_sso' + subject_type: SubjectType token: string token_id: string workspaces?: Array @@ -426,6 +426,8 @@ export type SimpleResultResponse = { result: string } +export type SubjectType = 'account' | 'external_sso' + export type SupportedAppType = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' export type TaskStopResponse = { @@ -739,6 +741,13 @@ export type GetAppsByAppIdHumanInputFormsByFormTokenData = { url: '/apps/{app_id}/human-input-forms/{form_token}' } +export type GetAppsByAppIdHumanInputFormsByFormTokenErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdHumanInputFormsByFormTokenError = + GetAppsByAppIdHumanInputFormsByFormTokenErrors[keyof GetAppsByAppIdHumanInputFormsByFormTokenErrors] + export type GetAppsByAppIdHumanInputFormsByFormTokenResponses = { 200: HumanInputFormDefinitionResponse } @@ -784,6 +793,13 @@ export type GetAppsByAppIdTasksByTaskIdEventsData = { url: '/apps/{app_id}/tasks/{task_id}/events' } +export type GetAppsByAppIdTasksByTaskIdEventsErrors = { + default: ErrorBody +} + +export type GetAppsByAppIdTasksByTaskIdEventsError = + GetAppsByAppIdTasksByTaskIdEventsErrors[keyof GetAppsByAppIdTasksByTaskIdEventsErrors] + export type GetAppsByAppIdTasksByTaskIdEventsResponses = { 200: EventStreamResponse } @@ -826,6 +842,7 @@ export type PostAppsByAppIdRunData = { export type PostAppsByAppIdRunErrors = { 422: ErrorBody + default: ErrorBody } export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors] diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 55ddcc32f6e..722ff155c30 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -552,6 +552,11 @@ export const zSimpleResultResponse = z.object({ result: z.string(), }) +/** + * SubjectType + */ +export const zSubjectType = z.enum(['account', 'external_sso']) + /** * SupportedAppType * @@ -673,7 +678,7 @@ export const zAccountResponse = z.object({ default_workspace_id: z.string().nullish(), subject_email: z.string().nullish(), subject_issuer: z.string().nullish(), - subject_type: z.string(), + subject_type: zSubjectType, workspaces: z.array(zWorkspacePayload).optional().default([]), }) @@ -686,7 +691,7 @@ export const zDeviceTokenResponse = z.object({ expires_at: z.string(), subject_email: z.string().nullish(), subject_issuer: z.string().nullish(), - subject_type: z.enum(['account', 'external_sso']), + subject_type: zSubjectType, token: z.string(), token_id: z.string(), workspaces: z.array(zWorkspacePayload).optional().default([]),