diff --git a/api/controllers/openapi/app_create.py b/api/controllers/openapi/app_create.py index a5c6af1d69f..bd40188d962 100644 --- a/api/controllers/openapi/app_create.py +++ b/api/controllers/openapi/app_create.py @@ -10,7 +10,6 @@ from typing import Final, Literal from flask_restx import Resource -from constants.personal_access_token import PersonalAccessTokenPolicy from controllers.common.rbac import RBACPermission from controllers.openapi import openapi_ns from controllers.openapi._contract import Example, Kind, endpoint @@ -42,7 +41,6 @@ class WorkflowAppCreateApi(Resource): kind=Kind.OBJECT, summary="Create a blank workflow app; export its DSL to start building", examples=(Example(title="Create a workflow app", input={"name": "Release notes"}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=_CREATE_GUARDS, body=CreateAppPayload, returns=(HTTPStatus.CREATED, CreatedAppResponse, "Created"), diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index 6a6b7fbb44b..110a341c386 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -8,7 +8,6 @@ from flask_restx import Resource from werkzeug.exceptions import BadRequest, NotFound from constants.oauth_bearer import Scope -from constants.personal_access_token import PersonalAccessTokenPolicy from controllers.common.errors import ForbiddenError, NotFoundError from controllers.common.rbac import RBACPermission from controllers.openapi import openapi_ns @@ -114,7 +113,6 @@ class AppDslImportApi(Resource): @endpoint( context="workspace", op="import.console_app.dsl", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, effect=Effect.DESTRUCTIVE, summary="Import an app from DSL text or URL", @@ -215,7 +213,6 @@ class AppDslImportConfirmApi(Resource): @endpoint( context="workspace", op="confirm.console_app.dsl_import", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, effect=Effect.DESTRUCTIVE, summary="Confirm a pending DSL import", @@ -252,7 +249,6 @@ class AppDslExportApi(Resource): @endpoint( context="app", op="export.console_app.dsl", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Export app DSL as YAML text inside a JSON object", examples=( @@ -295,7 +291,6 @@ class AppDslCheckDependenciesApi(Resource): @endpoint( context="workspace", op="check.console_app.dependency", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Check plugin dependencies of an app", examples=(Example(title="Check which plugins an app needs", input={"app_id": ""}),), diff --git a/api/controllers/openapi/app_info.py b/api/controllers/openapi/app_info.py index 7e0829ed6d0..be3d0273200 100644 --- a/api/controllers/openapi/app_info.py +++ b/api/controllers/openapi/app_info.py @@ -8,7 +8,6 @@ from typing import Final from flask_restx import Resource from constants.oauth_bearer import Scope -from constants.personal_access_token import PersonalAccessTokenPolicy from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission from controllers.openapi import _app_settings as settings from controllers.openapi import openapi_ns @@ -49,7 +48,6 @@ class WorkflowAppInfoApi(Resource): kind=Kind.OBJECT, summary="Name, description, icon and run cap of a workflow app", examples=(_INFO_EXAMPLE,), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=account_settings_guards(_INFO_READ, scope=Scope.APPS_READ, modes=(AppMode.WORKFLOW,), roles=None), returns=(HTTPStatus.OK, AppSettingsInfo, "App info"), ) @@ -58,7 +56,6 @@ class WorkflowAppInfoApi(Resource): @endpoint( op="set.app_info.workflow", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Change a workflow app's info; only the fields passed change", examples=(_RENAME_EXAMPLE,), diff --git a/api/controllers/openapi/app_release.py b/api/controllers/openapi/app_release.py index 655c18a3cd4..c04f0f15fcc 100644 --- a/api/controllers/openapi/app_release.py +++ b/api/controllers/openapi/app_release.py @@ -4,7 +4,6 @@ from http import HTTPStatus from flask_restx import Resource -from constants.personal_access_token import PersonalAccessTokenPolicy from controllers.openapi import openapi_ns from controllers.openapi._contract import Example, Kind, endpoint, op_of from controllers.openapi._models import ( @@ -54,7 +53,6 @@ class ReleaseCheckApi(Resource): kind=Kind.OBJECT, summary="Is the draft ready to publish: draft valid, last test passed on this draft, and what changed", examples=(Example(title="Before publishing", input={"app_id": ""}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=VERSION_READ_GUARDS, returns=(HTTPStatus.OK, ReleaseCheckResponse, "Release check"), ) diff --git a/api/controllers/openapi/app_workflow.py b/api/controllers/openapi/app_workflow.py index 669de88b7f4..35d595a34c8 100644 --- a/api/controllers/openapi/app_workflow.py +++ b/api/controllers/openapi/app_workflow.py @@ -12,7 +12,6 @@ from werkzeug.exceptions import BadRequest from constants import HIDDEN_VALUE from constants.oauth_bearer import Scope -from constants.personal_access_token import PersonalAccessTokenPolicy from controllers.common.fields import SimpleResultResponse from controllers.common.rbac import RBACPermission from controllers.openapi import openapi_ns @@ -114,7 +113,6 @@ class AppRunListApi(Resource): input={"app_id": "", "triggered_from": "debugging", "limit": 1}, ), ), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=_RUN_READ_GUARDS, query=RunListQuery, returns=(HTTPStatus.OK, RunListResponse, "Run list"), @@ -148,7 +146,6 @@ class AppRunDescribeApi(Resource): kind=Kind.OBJECT, summary="One run: inputs, outputs, error, tokens and timing", examples=(Example(title="Read one run", input={"app_id": "", "run_id": ""}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=_RUN_READ_GUARDS, returns=(HTTPStatus.OK, WorkflowRunDetailResponse, "Run detail"), ) @@ -164,7 +161,6 @@ class AppRunNodeListApi(Resource): kind=Kind.OBJECT, summary="Each node step of a run, to find the one that failed", examples=(Example(title="Node steps of one run", input={"app_id": "", "run_id": ""}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=_RUN_READ_GUARDS, returns=(HTTPStatus.OK, WorkflowRunNodeExecutionListResponse, "Node steps"), ) @@ -226,7 +222,6 @@ class AppVersionListApi(Resource): Example(title="Latest versions", input={"app_id": ""}), Example(title="Only named versions", input={"app_id": "", "named_only": True}), ), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=VERSION_READ_GUARDS, query=VersionListQuery, returns=(HTTPStatus.OK, VersionListResponse, "Version list"), @@ -330,7 +325,6 @@ class AppEnvListApi(Resource): summary="Environment variables of the draft. Each has an id, which set and delete take, " "and a name, which nodes use. Secrets with a value are masked; empty secrets read as empty", examples=(Example(title="List environment variables", input={"app_id": ""}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=VERSION_READ_GUARDS, returns=(HTTPStatus.OK, EnvVariableListResponse, "Environment variables"), ) diff --git a/api/controllers/openapi/knowledge_bases.py b/api/controllers/openapi/knowledge_bases.py index 0a432d6fbc9..9749c45632a 100644 --- a/api/controllers/openapi/knowledge_bases.py +++ b/api/controllers/openapi/knowledge_bases.py @@ -8,7 +8,6 @@ from typing import Any from flask_restx import Resource -from constants.personal_access_token import PersonalAccessTokenPolicy from controllers.openapi import openapi_ns from controllers.openapi._contract import Example, Kind, endpoint from controllers.openapi._models import ( @@ -43,7 +42,6 @@ class KnowledgeBasesApi(Resource): kind=Kind.LIST, summary="Knowledge bases you can use, each with the knowledge-retrieval node data to paste", examples=(Example(title="Find product docs", input={"query": "product"}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=WORKSPACE_READ_GUARDS, query=KnowledgeBaseListQuery, returns=(HTTPStatus.OK, KnowledgeBaseListResponse, "Knowledge bases"), diff --git a/api/controllers/openapi/model_providers.py b/api/controllers/openapi/model_providers.py index 4ade9c205af..e29486b3e22 100644 --- a/api/controllers/openapi/model_providers.py +++ b/api/controllers/openapi/model_providers.py @@ -200,6 +200,7 @@ def model_rows(models: Iterable[ModelWithProviderEntity], *, words: str, languag class ModelProviderApi(Resource): @endpoint( op="describe.model_provider", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="One model provider with its credential form and saved credentials (names only)", examples=(Example(title="OpenAI", input={"provider": _PROVIDER_EXAMPLE}),), @@ -264,6 +265,7 @@ class ModelProviderApi(Resource): class ModelProviderCredentialsApi(Resource): @endpoint( op="create.model_provider.credential", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Save a credential for a model provider; the provider checks it first", examples=( @@ -289,6 +291,7 @@ class ModelProviderCredentialsApi(Resource): class ModelProviderCredentialApi(Resource): @endpoint( op="set.model_provider.credential", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Replace a saved model provider credential", examples=( @@ -329,13 +332,13 @@ class ModelProviderCredentialApi(Resource): class ModelsApi(Resource): @endpoint( op="get.model", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.LIST, summary="Models in the workspace across providers, with whether each can be used now", examples=( Example(title="LLMs", input={"model_type": "llm"}), Example(title="OpenAI models", input={"provider": _PROVIDER_EXAMPLE}), ), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=WORKSPACE_READ_GUARDS, query=ModelListQuery, returns=(HTTPStatus.OK, ModelListResponse, "Models"), @@ -362,6 +365,7 @@ class ModelsApi(Resource): class ModelCredentialsApi(Resource): @endpoint( op="create.model.credential", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Save a credential for one model you name, for providers such as OpenAI-compatible or Ollama; " "the first credential of a model not yet set up becomes its active credential", @@ -398,6 +402,7 @@ class ModelCredentialsApi(Resource): class ModelCredentialApi(Resource): @endpoint( op="set.model.credential", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Replace a saved credential of one model", examples=( diff --git a/api/controllers/openapi/plugins.py b/api/controllers/openapi/plugins.py index eb81d51976b..cfd3a48333f 100644 --- a/api/controllers/openapi/plugins.py +++ b/api/controllers/openapi/plugins.py @@ -149,6 +149,7 @@ def _provider_hints(workspace_id: str, plugin_ids: Sequence[str]) -> list[Hint]: class MarketplacePluginsApi(Resource): @endpoint( op="get.marketplace.plugin", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.LIST, summary="Search the marketplace for plugins to install; each hit says if it is installed here", examples=( @@ -194,10 +195,10 @@ class MarketplacePluginsApi(Resource): class PluginsApi(Resource): @endpoint( op="get.plugin", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Plugins installed in the workspace, with the provider ids each one adds", examples=(Example(title="Installed model plugins", input={"category": "model"}),), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=WORKSPACE_READ_GUARDS, query=PluginListQuery, returns=(HTTPStatus.OK, PluginListResponse, "Installed plugins"), @@ -232,6 +233,7 @@ class PluginsApi(Resource): class PluginInstallApi(Resource): @endpoint( op="install.plugin", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Start installing marketplace plugins; finishes in the background, check describe.plugin.task", examples=(Example(title="Install one plugin", input={"identifiers": ["langgenius/openai:0.2.1@"]}),), @@ -253,6 +255,7 @@ class PluginInstallApi(Resource): class PluginTaskApi(Resource): @endpoint( op="describe.plugin.task", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Progress of a plugin install or upgrade; check again while pending or running", examples=(Example(title="Check an install", input={"task_id": ""}),), @@ -288,6 +291,7 @@ class PluginTaskApi(Resource): class PluginApi(Resource): @endpoint( op="delete.plugin", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Uninstall a plugin from the workspace", examples=(Example(title="Uninstall OpenAI", input={"plugin_id": "langgenius/openai"}),), diff --git a/api/controllers/openapi/tool_providers.py b/api/controllers/openapi/tool_providers.py index baee6f30573..a3918e979ba 100644 --- a/api/controllers/openapi/tool_providers.py +++ b/api/controllers/openapi/tool_providers.py @@ -192,13 +192,13 @@ def tool_rows( class ToolsApi(Resource): @endpoint( op="get.tool", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.LIST, summary="Tools a workflow can call here, each with a ready tool node data", examples=( Example(title="Find GitHub tools", input={"query": "github"}), Example(title="Workflows published as tools", input={"provider_type": "workflow"}), ), - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, requirements=WORKSPACE_READ_GUARDS, query=ToolListQuery, returns=(HTTPStatus.OK, ToolListResponse, "Tools"), @@ -224,6 +224,7 @@ class ToolsApi(Resource): class ToolProviderApi(Resource): @endpoint( op="describe.tool_provider", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="One tool provider with its API-key form and saved credentials (names only)", examples=(Example(title="Tavily", input={"provider": _PROVIDER_EXAMPLE}),), @@ -276,6 +277,7 @@ class ToolProviderApi(Resource): class ToolProviderCredentialsApi(Resource): @endpoint( op="create.tool_provider.credential", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Save an API-key credential for a tool provider; the provider checks it first", examples=( @@ -307,6 +309,7 @@ class ToolProviderCredentialsApi(Resource): class ToolProviderCredentialApi(Resource): @endpoint( op="set.tool_provider.credential", + pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Replace a saved tool provider credential", examples=( diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py index 4fcfe167aa9..3c537ca7a4b 100644 --- a/api/controllers/openapi/workspaces.py +++ b/api/controllers/openapi/workspaces.py @@ -101,7 +101,6 @@ class WorkspacesApi(Resource): class WorkspaceByIdApi(Resource): @endpoint( op="describe.workspace", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Workspace detail", examples=( @@ -130,7 +129,6 @@ class WorkspaceSwitchApi(Resource): @endpoint( op="switch.workspace", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Server-side current workspace switch (shared with the web console)", examples=(Example(title="Make a workspace current on the server", input={"workspace_id": ""}),), @@ -162,7 +160,6 @@ class WorkspaceMembersApi(Resource): @endpoint( op="get.workspace.member", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.LIST, summary="List workspace members", examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),), @@ -187,7 +184,6 @@ class WorkspaceMembersApi(Resource): @endpoint( op="invite.workspace.member", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Invite a member by email", examples=( @@ -239,7 +235,6 @@ class WorkspaceMemberApi(Resource): @endpoint( op="delete.workspace.member", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Remove a member", examples=(Example(title="Remove a member by account id", input={"member_id": ""}),), @@ -269,7 +264,6 @@ class WorkspaceMemberApi(Resource): @endpoint( op="set.workspace.member_role", - pat_policy=PersonalAccessTokenPolicy.WORKSPACE_OPERATIONS, kind=Kind.OBJECT, summary="Change a member's role", examples=( diff --git a/api/tests/unit_tests/controllers/console/test_personal_access_token.py b/api/tests/unit_tests/controllers/console/test_personal_access_token.py index 1a9bc640c0e..0022e3edb4e 100644 --- a/api/tests/unit_tests/controllers/console/test_personal_access_token.py +++ b/api/tests/unit_tests/controllers/console/test_personal_access_token.py @@ -23,7 +23,7 @@ from extensions.ext_redis import RedisClientWrapper from libs import rate_limit from libs.passport import PassportService from libs.token import generate_csrf_token -from models import App +from models import App, AppMode from models.account import Tenant, TenantAccountJoin, TenantAccountRole from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.model_factories import make_account, make_app, make_tenant @@ -536,7 +536,98 @@ def test_app_scope_filters_discovery_and_workspace_scope_cannot_bypass_it( assert listed.get_json()["total"] == 1 -def test_selected_workspace_permits_member_reads_only_in_that_workspace( +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/workspaces/{id}"), + ("POST", "/workspaces/{id}:switch"), + ("GET", "/workspaces/{id}/members"), + ("POST", "/workspaces/{id}/members"), + ("PATCH", "/workspaces/{id}/members/{id}"), + ("DELETE", "/workspaces/{id}/members/{id}"), + ("POST", "/workspaces/{id}/apps/workflow"), + ("POST", "/workspaces/{id}/apps/imports"), + ("GET", "/workspaces/{id}/knowledge-bases"), + ("PATCH", "/apps/{id}/app-info/workflow"), + ("POST", "/apps/{id}:publish"), + ("GET", "/apps/{id}/dsl"), + ("GET", "/apps/{id}/versions"), + ], +) +def test_pat_rejects_management_outside_phase_one( + client: FlaskClient, sqlite_session_factory: sessionmaker[Session], method: str, path: str +) -> None: + account = client.application.config["TEST_BROWSER_ACCOUNT"] + target_id = str(uuid4()) + workspace = make_tenant(tenant_id=target_id) + app = make_app(app_id=target_id, tenant_id=target_id, mode=AppMode.WORKFLOW) + with sqlite_session_factory.begin() as session: + session.add_all( + [ + workspace, + app, + TenantAccountJoin( + current=True, tenant_id=workspace.id, account_id=account.id, role=TenantAccountRole.OWNER + ), + ] + ) + token = client.post(BASE, json={"name": "all scopes"}).get_json() + headers = { + "Authorization": f"Bearer {token['secret']}", + CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER], + } + response = client.open("/openapi/v1" + path.format(id=target_id), method=method, json={}, headers=headers) + assert response.status_code == 403, response.get_json() + assert response.get_json()["message"] == "personal_access_token_not_supported" + + +@pytest.mark.parametrize("rbac_enabled", [True], indirect=True) +@pytest.mark.parametrize("allowed", [True, False]) +@pytest.mark.parametrize( + ("method", "suffix"), + [ + ("POST", "model-providers/openai/credentials"), + ("PATCH", "model-providers/openai/credentials/credential"), + ("POST", "model-providers/openai/models/credentials"), + ("PATCH", "model-providers/openai/models/credentials/credential"), + ("POST", "tool-providers/custom/credentials"), + ("PATCH", "tool-providers/custom/credentials/credential"), + ], +) +def test_pat_credential_management_still_requires_rbac( + client: FlaskClient, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + method: str, + suffix: str, + allowed: bool, +) -> None: + from tests.helpers.unit.rbac_fakes import build_rbac_domain + + domain = build_rbac_domain(sqlite_session_factory, monkeypatch) + domain.transport.response = {"allowed": allowed} + account = client.application.config["TEST_BROWSER_ACCOUNT"] + workspace = make_tenant(tenant_id=str(uuid4())) + with sqlite_session_factory.begin() as session: + session.add_all( + [ + workspace, + TenantAccountJoin( + current=True, tenant_id=workspace.id, account_id=account.id, role=TenantAccountRole.NORMAL + ), + ] + ) + token = client.post(BASE, json={"name": "credentials", "resource_scope": {"mode": "none"}}).get_json() + headers = { + "Authorization": f"Bearer {token['secret']}", + CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER], + } + response = client.open(f"/openapi/v1/workspaces/{workspace.id}/{suffix}", method=method, json={}, headers=headers) + assert response.status_code == (422 if allowed else 403), response.get_json() + assert [request.endpoint for request in domain.transport.requests] == ["/rbac/check-access"] + + +def test_selected_workspace_permits_model_requests_only_in_that_workspace( client: FlaskClient, sqlite_session_factory: sessionmaker[Session], ) -> None: @@ -571,16 +662,14 @@ def test_selected_workspace_permits_member_reads_only_in_that_workspace( "Authorization": f"Bearer {secret}", CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER], } - members = client.get(f"/openapi/v1/workspaces/{first.id}/members", headers=headers) - assert members.status_code == 200, members.get_json() - assert client.get(f"/openapi/v1/workspaces/{second.id}/members", headers=headers).status_code == 403 + models = client.get(f"/openapi/v1/workspaces/{first.id}/models?limit=0", headers=headers) + assert models.status_code == 422, models.get_json() + assert client.get(f"/openapi/v1/workspaces/{second.id}/models?limit=0", headers=headers).status_code == 403 listed = client.get("/openapi/v1/workspaces", headers=headers) assert [row["id"] for row in listed.get_json()["data"]] == [first.id] -@pytest.mark.parametrize( - "operation", ["workflow:run", "chat:run", "advanced-chat:run", "completion:run", "files", "dsl-import"] -) +@pytest.mark.parametrize("operation", ["workflow:run", "chat:run", "advanced-chat:run", "completion:run", "files"]) def test_authorized_pat_reaches_existing_business_request_validation( client: FlaskClient, sqlite_session_factory: sessionmaker[Session], @@ -608,11 +697,7 @@ def test_authorized_pat_reaches_existing_business_request_validation( "Authorization": f"Bearer {secret}", CATALOG_HEADER: client.get("/openapi/v1/_catalog").headers[CATALOG_HEADER], } - url = ( - f"/openapi/v1/workspaces/{workspace.id}/apps/imports" - if operation == "dsl-import" - else f"/openapi/v1/apps/{app.id}/{operation}" - ) + url = f"/openapi/v1/apps/{app.id}/{operation}" # Invalid input must be rejected by the existing contract after PAT admission, # without executing workflows, invoking models or performing an upload/import. response = client.post(url, json={}, headers=headers) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py b/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py index 88dca58dcc5..135e9f5302d 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_requirements.py @@ -2515,6 +2515,34 @@ class TestAuthMatrixCases: 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.