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 typing import Final 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 Forbidden, ImATeapot, NotFound 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 constants.oauth_bearer import Scope, TokenType from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission, Workspace from controllers.openapi import bp as openapi_bp from controllers.openapi._app_settings import REGULAR_MODES from controllers.openapi._catalog import CATALOG_HEADER, catalog_for from controllers.openapi._contract import CapRequestBody, op_of from controllers.openapi._errors import ( OpenApiErrorCode, OpenApiErrorFormatter, ServiceApiDisabled, SkillsDisabled, WebAppAccessRequiresEE, ) from controllers.openapi._models import ServiceApiPatch from controllers.openapi.app_info import AgentServiceApiApi, ServiceApiApi from controllers.openapi.auth import subjects from controllers.openapi.auth.context import RouteContractError from controllers.openapi.auth.requirements import ( CheckAppAccess, CheckAppApiEnabled, CheckAppMode, CheckAppQuota, CheckPluginInstallSetting, CheckRBACPermission, CheckScope, CheckSkillEnabled, CheckSubject, CheckWebAppAuthEnterprise, CheckWorkspaceInvitationQuota, CheckWorkspaceMember, CheckWorkspaceRole, Requirement, assert_license_valid, ) from controllers.openapi.auth.spec import EndpointSpec from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject from controllers.openapi.human_input_form import CheckFormSurface from enums import DeploymentEdition from enums import WebAppAccessMode as _auth_matrix_WebAppAccessMode from extensions.ext_application_services import ApplicationServices from libs.oauth_bearer import BearerAuthenticator, ResolvedRow, sha256_hex from models import AppMode as _requirements_AppMode from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import EndUserType from models.model import App, EndUser from models.model import AppMode as _auth_matrix_AppMode from models.oauth import OAuthAccessToken from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings from services.enterprise.enterprise_service import WebAppAccessMode as _requirements_WebAppAccessMode from services.entities.feature_entities import ( LicenseStatus, SystemFeatureModel, ) from services.system_feature_service import SystemFeatureService from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.controllers.openapi.auth._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" class TestRequirementsCases: @staticmethod 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, ) @staticmethod @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() @staticmethod 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) @staticmethod @pytest.mark.parametrize( ("mode", "view"), [ (_requirements_AppMode.CHAT, ServiceApiApi.patch), (_requirements_AppMode.WORKFLOW, ServiceApiApi.patch), (_requirements_AppMode.AGENT, AgentServiceApiApi.patch), ], ) def test_a_disabled_service_api_hints_the_op_that_turns_it_on( sqlite_session: Session, mode: _requirements_AppMode, view: Callable[..., object] ) -> None: persist(sqlite_session, make_app(enable_api=False, mode=mode)) subject = account_subject() with pytest.raises(ServiceApiDisabled) as raised: CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session) body = OpenApiErrorFormatter().finalize(raised.value, {}, raised.value.code or 0) assert (body["status"], body["code"]) == (403, OpenApiErrorCode.SERVICE_API_DISABLED) [hint] = body["hints"] assert hint["op"] == op_of(view) assert hint["input"] == {"app_id": APP_ID, "enabled": True} ServiceApiPatch.model_validate({k: v for k, v in hint["input"].items() if k != "app_id"}) @staticmethod def test_an_enabled_service_api_passes(sqlite_session: Session) -> None: persist(sqlite_session, make_app(enable_api=True)) subject = account_subject() CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session) @staticmethod def test_check_skill_enabled_follows_the_server_flag( sqlite_session: Session, config_overrides: Callable[..., None] ) -> None: subject = account_subject() ctx = make_ctx(sqlite_session, subject=subject) config_overrides(ENABLE_SKILL=True) CheckSkillEnabled().run(subject, ctx, sqlite_session) config_overrides(ENABLE_SKILL=False) with pytest.raises(SkillsDisabled): CheckSkillEnabled().run(subject, ctx, 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(_requirements_WebAppAccessMode.PRIVATE.value)): with pytest.raises(Forbidden, match="cannot resolve user for private app check"): CheckAppAccess().run(subject, ctx, sqlite_session) """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 route carries. A route that changes its tuple fails the wiring test; a tuple that changes its answers fails its representative's rows. """ 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: _auth_matrix_WebAppAccessMode | None = _auth_matrix_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, ServiceApiDisabled.description) 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", ) _RUN_TRAITS = frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}) ROUTES: tuple[Route, ...] = ( Route( "knowledge_fs.ls", "GET", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:ls", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_fs.tree", "GET", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:tree", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_fs.grep", "GET", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:grep", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_fs.find", "GET", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:find", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_fs.diff", "POST", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:diff", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_fs.cat", "GET", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:cat", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_fs.stat", "GET", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:stat", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route("describe.account", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})), Route("account.sessions.revoke_self", "DELETE", "/account/sessions/self", frozenset({Trait.ACCOUNT_PRIMARY})), Route("get.account.session", "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( "plugins.marketplace", "GET", "/workspaces/{workspace_id}/marketplace/plugins", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route("plugins.list", "GET", "/workspaces/{workspace_id}/plugins", frozenset({Trait.ACCOUNT_PRIMARY})), Route("plugins.install", "POST", "/workspaces/{workspace_id}/plugins:install", frozenset({Trait.ACCOUNT_PRIMARY})), Route( "plugins.task", "GET", "/workspaces/{workspace_id}/plugin-tasks/{task_id}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "plugins.delete", "DELETE", "/workspaces/{workspace_id}/plugins/{plugin_id}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "model_providers.describe", "GET", "/workspaces/{workspace_id}/model-providers/{provider}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "model_providers.credential.create", "POST", "/workspaces/{workspace_id}/model-providers/{provider}/credentials", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "model_providers.credential.set", "PATCH", "/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route("tools.list", "GET", "/workspaces/{workspace_id}/tools", frozenset({Trait.ACCOUNT_PRIMARY})), Route( "tool_providers.describe", "GET", "/workspaces/{workspace_id}/tool-providers/{provider}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "tool_providers.credential.create", "POST", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "tool_providers.credential.set", "PATCH", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route("skills.list", "GET", "/workspaces/{workspace_id}/skills", frozenset({Trait.ACCOUNT_PRIMARY})), Route("skills.describe", "GET", "/workspaces/{workspace_id}/skills/{skill_id}", frozenset({Trait.ACCOUNT_PRIMARY})), Route( "skills.versions", "GET", "/workspaces/{workspace_id}/skills/{skill_id}/versions", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "skills.export", "GET", "/workspaces/{workspace_id}/skills/{skill_id}/archive", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "skills.publish", "POST", "/workspaces/{workspace_id}/skills/{skill_id}:publish", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "knowledge_bases.list", "GET", "/workspaces/{workspace_id}/knowledge-bases", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "models.list", "GET", "/workspaces/{workspace_id}/models", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "models.credential.create", "POST", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "models.credential.set", "PATCH", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route("skills.import", "POST", "/workspaces/{workspace_id}/skills/imports", frozenset({Trait.ACCOUNT_PRIMARY})), Route( "app_dsl.import", "POST", "/workspaces/{workspace_id}/apps/imports", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "app_dsl.check", "POST", "/workspaces/{workspace_id}/apps/imports:check", 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.workflow", "POST", "/apps/{app_id}/workflow:run", _RUN_TRAITS), Route("app_run.chat", "POST", "/apps/{app_id}/chat:run", _RUN_TRAITS), Route("app_run.advanced_chat", "POST", "/apps/{app_id}/advanced-chat:run", _RUN_TRAITS), Route("app_run.completion", "POST", "/apps/{app_id}/completion:run", _RUN_TRAITS), Route( "app_run.draft.workflow", "POST", "/apps/{app_id}/draft/workflow:run", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_run.draft.advanced_chat", "POST", "/apps/{app_id}/draft/advanced-chat:run", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route("app_workflow.run.list", "GET", "/apps/{app_id}/runs", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})), Route( "app_workflow.run.describe", "GET", "/apps/{app_id}/runs/{run_id}", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_workflow.run.nodes", "GET", "/apps/{app_id}/runs/{run_id}/nodes", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_workflow.publish", "POST", "/apps/{app_id}:publish", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}) ), Route( "app_workflow.version.list", "GET", "/apps/{app_id}/versions", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_workflow.version.restore", "POST", "/apps/{app_id}/versions/{version_id}:restore", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_workflow.release_check", "GET", "/apps/{app_id}/release:check", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route("app_workflow.env.list", "GET", "/apps/{app_id}/env", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})), Route( "app_workflow.env.set", "PUT", "/apps/{app_id}/env/{env_id}", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_workflow.env.delete", "DELETE", "/apps/{app_id}/env/{env_id}", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route("node_types.list", "GET", "/node-types", frozenset({Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE})), Route( "node_types.describe", "GET", "/node-types/{node_type}", frozenset({Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE}), ), Route( "app_create.workflow", "POST", "/workspaces/{workspace_id}/apps/workflow", frozenset({Trait.ACCOUNT_PRIMARY}) ), Route( "app_create.advanced_chat", "POST", "/workspaces/{workspace_id}/apps/advanced-chat", frozenset({Trait.ACCOUNT_PRIMARY}), ), Route( "app_workflow.node_run.workflow", "POST", "/apps/{app_id}/draft/workflow/nodes/{node_id}:run", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_workflow.node_run.advanced_chat", "POST", "/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.describe.workflow", "GET", "/apps/{app_id}/app-info/workflow", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.set.workflow", "PATCH", "/apps/{app_id}/app-info/workflow", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "service_api.describe", "GET", "/apps/{app_id}/service-api", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "service_api.set", "PATCH", "/apps/{app_id}/service-api", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.describe.advanced_chat", "GET", "/apps/{app_id}/app-info/advanced-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.set.advanced_chat", "PATCH", "/apps/{app_id}/app-info/advanced-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.describe.chat", "GET", "/apps/{app_id}/app-info/chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.set.chat", "PATCH", "/apps/{app_id}/app-info/chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.describe.agent_chat", "GET", "/apps/{app_id}/app-info/agent-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.set.agent_chat", "PATCH", "/apps/{app_id}/app-info/agent-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.describe.completion", "GET", "/apps/{app_id}/app-info/completion", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.set.completion", "PATCH", "/apps/{app_id}/app-info/completion", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.describe.agent", "GET", "/apps/{app_id}/app-info/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_info.set.agent", "PATCH", "/apps/{app_id}/app-info/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "service_api.describe.agent", "GET", "/apps/{app_id}/service-api/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "service_api.set.agent", "PATCH", "/apps/{app_id}/service-api/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.describe.workflow", "GET", "/apps/{app_id}/webapp/workflow", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.set.workflow", "PATCH", "/apps/{app_id}/webapp/workflow", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.reset", "POST", "/apps/{app_id}/webapp:reset", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp_access.describe", "GET", "/apps/{app_id}/webapp-access", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp_access.set", "PUT", "/apps/{app_id}/webapp-access", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.describe.advanced_chat", "GET", "/apps/{app_id}/webapp/advanced-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.set.advanced_chat", "PATCH", "/apps/{app_id}/webapp/advanced-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.describe.chat", "GET", "/apps/{app_id}/webapp/chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.set.chat", "PATCH", "/apps/{app_id}/webapp/chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.describe.agent_chat", "GET", "/apps/{app_id}/webapp/agent-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.set.agent_chat", "PATCH", "/apps/{app_id}/webapp/agent-chat", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.describe.completion", "GET", "/apps/{app_id}/webapp/completion", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.set.completion", "PATCH", "/apps/{app_id}/webapp/completion", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.describe.agent", "GET", "/apps/{app_id}/webapp/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.set.agent", "PATCH", "/apps/{app_id}/webapp/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp.reset.agent", "POST", "/apps/{app_id}/webapp/agent:reset", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp_access.describe.agent", "GET", "/apps/{app_id}/webapp-access/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "webapp_access.set.agent", "PUT", "/apps/{app_id}/webapp-access/agent", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}), ), Route( "app_run.stop", "POST", "/apps/{app_id}/runs/{run_id}:stop", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}), ), Route( "app_run.stop.task", "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}/runs/{run_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=_auth_matrix_WebAppAccessMode.PUBLIC, ), Case.EE_ACCOUNT_SSO_VERIFIED: Scenario( bearer=Bearer.ACCOUNT_MEMBER, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_WebAppAccessMode.SSO_VERIFIED, ), Case.EE_ACCOUNT_PRIVATE_ALL: Scenario( bearer=Bearer.ACCOUNT_MEMBER, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_WebAppAccessMode.PRIVATE_ALL, ), Case.EE_ACCOUNT_PRIVATE_PERMITTED: Scenario( bearer=Bearer.ACCOUNT_MEMBER, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_WebAppAccessMode.PRIVATE, private_app_permitted=True, ), Case.EE_ACCOUNT_PRIVATE_REFUSED: Scenario( bearer=Bearer.ACCOUNT_MEMBER, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_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=_auth_matrix_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=_auth_matrix_WebAppAccessMode.PUBLIC, ), Case.EE_EXTERNAL_SSO_VERIFIED: Scenario( bearer=Bearer.EXTERNAL, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_WebAppAccessMode.SSO_VERIFIED, ), Case.EE_EXTERNAL_PRIVATE_ALL: Scenario( bearer=Bearer.EXTERNAL, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_WebAppAccessMode.PRIVATE_ALL, ), Case.EE_EXTERNAL_PRIVATE: Scenario( bearer=Bearer.EXTERNAL, edition=DeploymentEdition.ENTERPRISE, webapp_auth=True, access_mode=_auth_matrix_WebAppAccessMode.PRIVATE, ), Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: Scenario( bearer=Bearer.EXTERNAL, edition=DeploymentEdition.ENTERPRISE, webapp_auth=False, access_mode=_auth_matrix_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 = "describe.account" 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, } _ACCOUNT_READER_APP: 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.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, } _ACCOUNT_EDITOR_APP: dict[Case, Expect] = { **_ACCOUNT_READER_APP, Case.LOW_ROLE: DENY_ROLE, Case.RBAC_ON_LOW_ROLE: ADMIT, } _ACCOUNT_READER_SETTINGS: dict[Case, Expect] = {**_ACCOUNT_READER_APP, Case.APP_API_DISABLED: ADMIT} _ACCOUNT_EDITOR_SETTINGS: dict[Case, Expect] = {**_ACCOUNT_EDITOR_APP, Case.APP_API_DISABLED: ADMIT} DENY_NEEDS_WEBAPP_EE = Expect(403, WebAppAccessRequiresEE.description) _WEBAPP_ACCESS_EDITOR: dict[Case, Expect] = { **_ACCOUNT_EDITOR_SETTINGS, Case.MEMBER: DENY_NEEDS_WEBAPP_EE, Case.APP_API_DISABLED: DENY_NEEDS_WEBAPP_EE, Case.FOREIGN_WORKSPACE_QUERY: DENY_NEEDS_WEBAPP_EE, Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_NEEDS_WEBAPP_EE, Case.RBAC_ON_LOW_ROLE: DENY_NEEDS_WEBAPP_EE, } _ANY_BEARER: dict[Case, Expect] = { **{case: ADMIT for case in Case if CASE_REQUIRES[case] <= {Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE}}, Case.WRONG_SUBJECT: DENY_SSO_NEEDS_EE, } """A route with no requirements admits every case that carries a valid bearer. The one exception is the router's own answer: an external-SSO bearer is refused outside enterprise.""" MATRIX: dict[str, dict[Case, Expect]] = { "describe.account": 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), "plugins.install": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC}, "skills.list": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC}, "skills.publish": dict(_ACCOUNT_MEMBER_WITH_ROLE), "skills.import": dict(_ACCOUNT_MEMBER_WITH_ROLE), "model_providers.credential.create": dict(_ACCOUNT_MEMBER_WITH_ROLE), "app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE), "app_dsl.check": dict(_ACCOUNT_MEMBER_WITH_ROLE), "app_create.workflow": dict(_ACCOUNT_MEMBER_WITH_ROLE), "node_types.list": dict(_ANY_BEARER), "app_workflow.node_run.workflow": dict(_ACCOUNT_EDITOR_APP), "app_info.describe.workflow": dict(_ACCOUNT_READER_SETTINGS), "app_info.set.workflow": dict(_ACCOUNT_EDITOR_SETTINGS), "service_api.describe": dict(_ACCOUNT_EDITOR_SETTINGS), "service_api.set": dict(_ACCOUNT_EDITOR_SETTINGS), "webapp.describe.workflow": dict(_ACCOUNT_READER_SETTINGS), "webapp.set.workflow": dict(_ACCOUNT_EDITOR_SETTINGS), "webapp.reset": dict(_ACCOUNT_EDITOR_SETTINGS), "webapp_access.describe": dict(_WEBAPP_ACCESS_EDITOR), "apps.describe": dict(_ACCOUNT_READER_APP), "app_dsl.export": dict(_ACCOUNT_EDITOR_APP), "app_run.draft.workflow": dict(_ACCOUNT_EDITOR_APP), "app_workflow.run.list": dict(_ACCOUNT_READER_APP), "app_workflow.run.describe": dict(_ACCOUNT_READER_APP), "app_workflow.run.nodes": dict(_ACCOUNT_READER_APP), "app_workflow.publish": dict(_ACCOUNT_EDITOR_APP), "app_workflow.version.list": dict(_ACCOUNT_EDITOR_APP), "app_workflow.version.restore": dict(_ACCOUNT_EDITOR_APP), "app_workflow.release_check": dict(_ACCOUNT_EDITOR_APP), "app_workflow.env.list": dict(_ACCOUNT_EDITOR_APP), "app_workflow.env.set": dict(_ACCOUNT_EDITOR_APP), "app_workflow.env.delete": dict(_ACCOUNT_EDITOR_APP), "app_run.workflow": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "app_run.chat": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "app_run.advanced_chat": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "app_run.completion": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "app_run.stop": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "app_run.stop.task": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "human_input_form.get": {**_DUAL_SUBJECT_RUN, Case.RBAC_ON_DENIED: ADMIT}, "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_SKILL_READ = ( CheckSkillEnabled(), *_REQ_ACCOUNT_WORKSPACE_READ_MEMBER, CheckRBACPermission(RBACCheck(RBACPermission.SKILL_VIEW, Workspace())), ) _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), CheckAppAccess(), ) _REQ_RUN_FORM = (*_REQ_RUN, CheckFormSurface()) _REQ_RELEASE = ( CheckSubject(allowed=_ACCOUNT), CheckAppApiEnabled(), CheckWorkspaceMember(), CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT), CheckScope(Scope.WORKSPACE_WRITE), CheckRBACPermission(RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp())), CheckWorkspaceRole(_EDITOR_UP), ) _REQ_VERSION_READ = ( CheckSubject(allowed=_ACCOUNT), CheckAppApiEnabled(), CheckWorkspaceMember(), CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT), CheckScope(Scope.APPS_READ), CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())), CheckWorkspaceRole(_EDITOR_UP), ) _REQ_ENV_WRITE = ( CheckSubject(allowed=_ACCOUNT), CheckAppApiEnabled(), CheckWorkspaceMember(), CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT), CheckScope(Scope.WORKSPACE_WRITE), CheckRBACPermission(RBACCheck(RBACPermission.APP_EDIT, PlainApp())), CheckWorkspaceRole(_EDITOR_UP), ) _REQ_RUN_HISTORY = ( CheckSubject(allowed=_ACCOUNT), CheckAppApiEnabled(), CheckWorkspaceMember(), CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT), CheckScope(Scope.APPS_READ), CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())), ) _REQ_DRAFT_RUN = ( CheckSubject(allowed=_ACCOUNT), CheckAppApiEnabled(), CheckWorkspaceMember(), CheckScope(Scope.APPS_RUN), CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())), CheckWorkspaceRole(_EDITOR_UP), ) def _draft_req(mode: _auth_matrix_AppMode) -> tuple[Requirement, ...]: return (*_REQ_DRAFT_RUN[:3], CheckAppMode(mode), *_REQ_DRAFT_RUN[3:]) _REQ_NODE_TYPES: tuple[Requirement, ...] = () _REQ_APP_CREATE = ( CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_WRITE), CheckWorkspaceMember(), CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, Workspace())), CheckWorkspaceRole(_EDITOR_UP), CheckAppQuota(), ) _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(), ) def _req_plugin_write(permission: RBACPermission) -> tuple[Requirement, ...]: return ( CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_WRITE), CheckWorkspaceMember(), CheckRBACPermission(RBACCheck(permission, Workspace())), CheckPluginInstallSetting(), ) def _req_admin_write(permission: RBACPermission) -> tuple[Requirement, ...]: return ( CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_WRITE), CheckWorkspaceMember(), CheckRBACPermission(RBACCheck(permission, Workspace())), CheckWorkspaceRole(_OWNER_ADMIN), ) def _req_skill_write(permission: RBACPermission) -> tuple[Requirement, ...]: return ( CheckSkillEnabled(), CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_WRITE), CheckWorkspaceMember(), CheckRBACPermission(RBACCheck(permission, Workspace())), CheckWorkspaceRole(_EDITOR_UP), ) _REQ_PLUGIN_TASK = ( CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_READ), CheckWorkspaceMember(), CheckPluginInstallSetting(), ) def _settings_req( modes: tuple[_auth_matrix_AppMode, ...], perm: RBACPermission, locator: PlainApp | AgentBehindApp, scope: Scope, roles: frozenset[TenantAccountRole] | None, ) -> tuple[Requirement, ...]: base: tuple[Requirement, ...] = ( CheckSubject(allowed=_ACCOUNT), CheckWorkspaceMember(), CheckAppMode(*modes), CheckScope(scope), CheckRBACPermission(RBACCheck(perm, locator)), ) return (*base, CheckWorkspaceRole(roles)) if roles is not None else base _RESOURCE_RUN = ( CheckSubject(allowed=(*_ACCOUNT_OR_EXTERNAL, ResourceAccessSubject)), CheckAppApiEnabled(), CheckWorkspaceMember(), CheckScope(Scope.APPS_RUN), CheckAppAccess(), ) DECLARED: dict[str, tuple[Requirement, ...]] = { "knowledge_fs.ls": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "knowledge_fs.tree": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "knowledge_fs.grep": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "knowledge_fs.find": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "knowledge_fs.diff": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "knowledge_fs.cat": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "knowledge_fs.stat": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "describe.account": _REQ_ACCOUNT_FULL, "account.sessions.revoke_self": _REQ_ACCOUNT_FULL, "get.account.session": _REQ_ACCOUNT_FULL, "account.sessions.revoke_one": _REQ_ACCOUNT_FULL, "apps.describe": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_APP_DESCRIBE[1:]), "apps.list": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_ACCOUNT_APPS_READ_MEMBER[1:]), "workspaces.list": ( CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_ACCOUNT_WORKSPACE_READ[1:], ), "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, CheckWorkspaceInvitationQuota()), "workspaces.members.remove": _REQ_MEMBER_MANAGE, "workspaces.members.update_role": _REQ_ROLE_MANAGE, "plugins.marketplace": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "plugins.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "plugins.install": _req_plugin_write(RBACPermission.PLUGIN_INSTALL), "plugins.task": _REQ_PLUGIN_TASK, "plugins.delete": _req_plugin_write(RBACPermission.PLUGIN_DELETE), "model_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "model_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE), "model_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE), "tools.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "tool_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "tool_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE), "tool_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE), "skills.list": _REQ_SKILL_READ, "skills.describe": _REQ_SKILL_READ, "skills.versions": _REQ_SKILL_READ, "skills.export": _REQ_SKILL_READ, "skills.publish": _req_skill_write(RBACPermission.SKILL_PUBLISH), "skills.import": ( *_req_skill_write(RBACPermission.SKILL_EDIT), CapRequestBody(max_bytes=lambda: 0, max_parts=lambda: 0), ), "knowledge_bases.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "models.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER, "models.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE), "models.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE), "app_dsl.import": _REQ_DSL_WORKSPACE, "app_dsl.check": _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.workflow": _RESOURCE_RUN, "app_run.chat": _RESOURCE_RUN, "app_run.advanced_chat": _RESOURCE_RUN, "app_run.completion": _RESOURCE_RUN, "app_run.draft.workflow": _draft_req(_auth_matrix_AppMode.WORKFLOW), "app_run.draft.advanced_chat": _draft_req(_auth_matrix_AppMode.ADVANCED_CHAT), "app_workflow.run.list": _REQ_RUN_HISTORY, "app_workflow.run.describe": _REQ_RUN_HISTORY, "app_workflow.run.nodes": _REQ_RUN_HISTORY, "app_workflow.publish": _REQ_RELEASE, "app_workflow.version.list": _REQ_VERSION_READ, "app_workflow.version.restore": _REQ_RELEASE, "app_workflow.release_check": _REQ_VERSION_READ, "app_workflow.env.list": _REQ_VERSION_READ, "app_workflow.env.set": _REQ_ENV_WRITE, "app_workflow.env.delete": _REQ_ENV_WRITE, "node_types.list": _REQ_NODE_TYPES, "node_types.describe": _REQ_NODE_TYPES, "app_create.workflow": _REQ_APP_CREATE, "app_create.advanced_chat": _REQ_APP_CREATE, "app_workflow.node_run.workflow": _draft_req(_auth_matrix_AppMode.WORKFLOW), "app_workflow.node_run.advanced_chat": _draft_req(_auth_matrix_AppMode.ADVANCED_CHAT), "app_info.describe.workflow": _settings_req( (_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "app_info.set.workflow": _settings_req( (_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP ), "service_api.describe": _settings_req( REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.APPS_READ, _EDITOR_UP ), "service_api.set": _settings_req( REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN ), "app_info.describe.advanced_chat": _settings_req( (_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "app_info.set.advanced_chat": _settings_req( (_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP ), "app_info.describe.chat": _settings_req( (_auth_matrix_AppMode.CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "app_info.set.chat": _settings_req( (_auth_matrix_AppMode.CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP ), "app_info.describe.agent_chat": _settings_req( (_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "app_info.set.agent_chat": _settings_req( (_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP ), "app_info.describe.completion": _settings_req( (_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "app_info.set.completion": _settings_req( (_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP ), "app_info.describe.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_PREVIEW, AgentBehindApp(), Scope.APPS_READ, None ), "app_info.set.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_EDIT, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP ), "service_api.describe.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp(), Scope.APPS_READ, _EDITOR_UP, ), "service_api.set.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN, ), "webapp.describe.workflow": _settings_req( (_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "webapp.set.workflow": _settings_req( (_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), "webapp.reset": _settings_req( REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN ), "webapp_access.describe": ( *_settings_req(REGULAR_MODES, RBACPermission.APP_ACCESS_CONFIG, PlainApp(), Scope.APPS_READ, _EDITOR_UP), CheckWebAppAuthEnterprise(), ), "webapp_access.set": ( *_settings_req(REGULAR_MODES, RBACPermission.APP_ACCESS_CONFIG, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP), CheckWebAppAuthEnterprise(), ), "webapp.describe.advanced_chat": _settings_req( (_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "webapp.set.advanced_chat": _settings_req( (_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), "webapp.describe.chat": _settings_req( (_auth_matrix_AppMode.CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "webapp.set.chat": _settings_req( (_auth_matrix_AppMode.CHAT,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), "webapp.describe.agent_chat": _settings_req( (_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "webapp.set.agent_chat": _settings_req( (_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), "webapp.describe.completion": _settings_req( (_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None ), "webapp.set.completion": _settings_req( (_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), "webapp.describe.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp(), Scope.APPS_READ, None ), "webapp.set.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), "webapp.reset.agent": _settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN, ), "webapp_access.describe.agent": ( *_settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_CONFIG, AgentBehindApp(), Scope.APPS_READ, _EDITOR_UP, ), CheckWebAppAuthEnterprise(), ), "webapp_access.set.agent": ( *_settings_req( (_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_CONFIG, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP, ), CheckWebAppAuthEnterprise(), ), "app_run.stop": _RESOURCE_RUN, "app_run.stop.task": _RESOURCE_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}) def _system_features( *, edition: DeploymentEdition, webapp_auth: bool, license_status: LicenseStatus ) -> SystemFeatureModel: features = SystemFeatureModel(agent_tracing_api_collection_enabled=False, 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: _auth_matrix_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, "run_id": str(uuid.uuid4()), "version_id": str(uuid.uuid4()), "skill_id": str(uuid.uuid4()), "env_id": str(uuid.uuid4()), "node_type": "llm", "node_id": "node-1", "plugin_id": "langgenius/openai", "provider": "langgenius/openai/openai", "credential_id": str(uuid.uuid4()), "model_type": "llm", } 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] = {CATALOG_HEADER: catalog_for(app)[1]} 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}, ) ) services = subjects.application_services() stack.enter_context( patch.object( services.app_scoped_end_users.commands, "get_or_create_end_user_by_type", side_effect=_end_user, ) ) stack.enter_context( patch( "services.enterprise.rbac_service.RBACService.CheckAccess.check", return_value=scenario.rbac_allows, ) ) stack.enter_context( patch.object( services.accounts.identity, "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) ) _PATH_PARAMS: Final = frozenset({"plugin_id", "provider"}) def _rule_path(route: Route) -> str: path = route.path for name in _PATH_PARAMS: path = path.replace(f"{{{name}}}", f"") return "/openapi/v1" + 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 @singledispatch def _config(_requirement: Requirement) -> object: return None @_config.register def _(requirement: CheckSubject) -> object: return requirement.allowed @_config.register def _(requirement: CheckAppMode) -> object: return requirement.modes @_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 ERROR_DEFAULT_RESPONSE: dict[str, object] = { "description": "Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorBody"}}}, } """Exactly what `@returns` registers as `("default", "Error", ErrorBody)`, and what `ExternalApi` adds to every other operation.""" EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = { ("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:ls"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:tree"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:grep"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:find"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("post", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:diff"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:cat"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:stat"): frozenset( {"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"} ), ("get", "/_catalog"): frozenset({"200", "default"}), ("get", "/workspaces/{workspace_id}/marketplace/plugins"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}/plugins"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/plugins:install"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}/plugin-tasks/{task_id}"): frozenset({"200", "default"}), ("delete", "/workspaces/{workspace_id}/plugins/{plugin_id}"): frozenset({"200", "default"}), ("get", "/workspaces/{workspace_id}/tools"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}/tool-providers/{provider}"): frozenset({"200", "default"}), ("post", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials"): frozenset({"201", "422", "default"}), ("patch", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}"): frozenset( {"200", "422", "default"} ), ("get", "/workspaces/{workspace_id}/model-providers/{provider}"): frozenset({"200", "default"}), ("post", "/workspaces/{workspace_id}/model-providers/{provider}/credentials"): frozenset({"201", "422", "default"}), ("patch", "/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}"): frozenset( {"200", "422", "default"} ), ("get", "/workspaces/{workspace_id}/knowledge-bases"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}/skills"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}/skills/{skill_id}"): frozenset({"200", "default"}), ("get", "/workspaces/{workspace_id}/skills/{skill_id}/versions"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}/skills/{skill_id}/archive"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/skills/{skill_id}:publish"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/skills/imports"): frozenset({"200", "201", "422", "default"}), ("get", "/workspaces/{workspace_id}/models"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials"): frozenset( {"201", "422", "default"} ), ("patch", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}"): frozenset( {"200", "422", "default"} ), ("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}/app-info/workflow"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/app-info/workflow"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/service-api"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/service-api"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/app-info/advanced-chat"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/app-info/advanced-chat"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/app-info/chat"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/app-info/chat"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/app-info/agent-chat"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/app-info/agent-chat"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/app-info/completion"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/app-info/completion"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/app-info/agent"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/app-info/agent"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/service-api/agent"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/service-api/agent"): 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", "422", "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}/runs/{run_id}/events"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/runs/{run_id}:stop"): frozenset({"200", "default"}), ("post", "/apps/{app_id}/tasks/{task_id}:stop"): frozenset({"200", "default"}), ("post", "/apps/{app_id}/workflow:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/chat:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/advanced-chat:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/completion:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/draft/workflow:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/draft/advanced-chat:run"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/runs"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/runs/{run_id}"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/runs/{run_id}/nodes"): frozenset({"200", "default"}), ("post", "/apps/{app_id}:publish"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/versions"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/versions/{version_id}:restore"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/release:check"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/env"): frozenset({"200", "default"}), ("put", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "422", "default"}), ("delete", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "default"}), # The catalog and the five device-flow rows document their 200 with a raw # `openapi_ns.response` rather than `@returns`; `ExternalApi` still gives them the # `default` error every operation documents, with the same `ErrorBody` schema. # Approve and deny run Console account admission; its setup and account-initialization # errors are generic on that surface (handled centrally by the client), so the guards # document nothing and the open `default` error covers them. Code, token, approve and # deny declare the RFC 8628-style `DeviceFlowErrorResponse` for the statuses their flow # errors use. ("post", "/oauth/device/approve"): frozenset({"200", "404", "409", "503", "default"}), ("post", "/oauth/device/code"): frozenset({"200", "400", "default"}), ("post", "/oauth/device/deny"): frozenset({"200", "404", "409", "503", "default"}), ("get", "/oauth/device/lookup"): frozenset({"200", "default"}), ("post", "/oauth/device/token"): frozenset({"200", "400", "default"}), ("get", "/node-types"): frozenset({"200", "default"}), ("get", "/node-types/{node_type}"): frozenset({"200", "default"}), ("post", "/apps/{app_id}/draft/workflow/nodes/{node_id}:run"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run"): frozenset({"200", "422", "default"}), ("post", "/workspaces/{workspace_id}/apps/workflow"): frozenset({"201", "422", "default"}), ("post", "/workspaces/{workspace_id}/apps/advanced-chat"): frozenset({"201", "422", "default"}), ("get", "/permitted-external-apps"): frozenset({"200", "422", "default"}), ("get", "/permitted-external-apps/{app_id}"): frozenset({"200", "422", "default"}), ("get", "/workspaces"): frozenset({"200", "422", "default"}), ("get", "/workspaces/{workspace_id}"): frozenset({"200", "default"}), ("post", "/workspaces/{workspace_id}:switch"): frozenset({"200", "default"}), ("post", "/workspaces/{workspace_id}/apps/imports"): frozenset({"200", "202", "400", "422", "default"}), ("post", "/workspaces/{workspace_id}/apps/imports:check"): frozenset({"200", "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"}), ("get", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/webapp:reset"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/webapp-access"): frozenset({"200", "default"}), ("put", "/apps/{app_id}/webapp-access"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/webapp/advanced-chat"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/webapp/advanced-chat"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/webapp/chat"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/webapp/chat"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/webapp/agent-chat"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/webapp/agent-chat"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/webapp/completion"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/webapp/completion"): frozenset({"200", "422", "default"}), ("get", "/apps/{app_id}/webapp/agent"): frozenset({"200", "default"}), ("patch", "/apps/{app_id}/webapp/agent"): frozenset({"200", "422", "default"}), ("post", "/apps/{app_id}/webapp/agent:reset"): frozenset({"200", "default"}), ("get", "/apps/{app_id}/webapp-access/agent"): frozenset({"200", "default"}), ("put", "/apps/{app_id}/webapp-access/agent"): frozenset({"200", "422", "default"}), } _HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"}) 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 class TestAuthMatrixCases: @staticmethod @pytest.fixture def token_rows() -> dict[str, ResolvedRow]: return {} @staticmethod @pytest.fixture(scope="module") def _matrix_app() -> Flask: """Register the matrix's unchanging routes once; each case gets a fresh client.""" app = create_flask_app_with_configs() app.config["TESTING"] = True app.secret_key = "openapi-auth-matrix" LoginManager(app) app.register_blueprint(openapi_bp) return app @staticmethod @pytest.fixture def matrix_app( _matrix_app: Flask, monkeypatch: pytest.MonkeyPatch, account_application_services: ApplicationServices ) -> Iterator[Flask]: """Keep rate-limit overrides and the admission probe scoped to each case.""" monkeypatch.setitem(_matrix_app.extensions, "application_services", account_application_services) 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 _matrix_app finally: user_logged_in.disconnect(_admit_on_mount) @staticmethod @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=_auth_matrix_AppMode.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 rows: list[object] = [ 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, ), ] 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) rows.append(row) 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") with sqlite_session_factory.begin() as session: session.add_all(rows) return built @staticmethod @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 @staticmethod def test_pat_workspace_management_is_limited_to_phase_one_routes(matrix_app: Flask) -> None: from constants.personal_access_token import PersonalAccessTokenPolicy supported = { route.id for route in ROUTES if (spec := _spec_for(matrix_app, route)) is not None and spec.pat_policy is PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS } assert supported == { "plugins.marketplace", "plugins.list", "plugins.install", "plugins.task", "plugins.delete", "model_providers.describe", "model_providers.credential.create", "model_providers.credential.set", "models.list", "models.credential.create", "models.credential.set", "tools.list", "tool_providers.describe", "tool_providers.credential.create", "tool_providers.credential.set", } @staticmethod 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, documentation and catalog 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) == 14 @staticmethod @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])] @staticmethod @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() @staticmethod def test_file_bearing_bodies_are_documented_as_multipart(openapi_document: dict[str, object]) -> None: """The exported contract names the wire form `_multipart.py` accepts: a body of only files is multipart alone, a run body is JSON or multipart with its JSON-text parts marked.""" paths = openapi_document["paths"] assert isinstance(paths, dict) upload = paths["/apps/{app_id}/files"]["post"]["requestBody"]["content"] assert set(upload) == {"multipart/form-data"} assert "encoding" not in upload["multipart/form-data"] run = paths["/apps/{app_id}/chat:run"]["post"]["requestBody"]["content"] assert set(run) == {"application/json", "multipart/form-data"} assert run["multipart/form-data"]["schema"] == run["application/json"]["schema"] encoding = run["multipart/form-data"]["encoding"] assert {"inputs", "query"} <= set(encoding) assert {"files", "attachments"}.isdisjoint(encoding) assert encoding["inputs"] == {"contentType": "application/json"} @staticmethod 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])}" ) @staticmethod 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