diff --git a/api/.env.example b/api/.env.example index f10f15f0b9175c..69260683b8f517 100644 --- a/api/.env.example +++ b/api/.env.example @@ -17,6 +17,13 @@ PYTHONIOENCODING=utf-8 # Console API base URL CONSOLE_API_URL=http://localhost:5001 + +# Optional internal SaaS policy API; defaults to BILLING_API_URL when empty. +NETWORK_ACCESS_API_URL= +# Current-IP preview only. Comma-separated CIDRs, e.g. 172.18.0.0/16. +# Required for Cloud IP previews. Match Gateway's proxy boundary; never trust caller +# headers unless the edge sanitizes XFF or appends its verified socket peer. +NETWORK_ACCESS_TRUSTED_PROXY_CIDRS= CONSOLE_WEB_URL=http://localhost:3000 # Service API base URL diff --git a/api/.importlinter b/api/.importlinter index 1c775479ed0f2f..5f9ab46613b7d9 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -171,6 +171,21 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:network-access-group-service-boundary] +name = Network access group application service is framework and persistence neutral +type = forbidden +source_modules = + services.network_access_group_service +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + sqlalchemy + werkzeug + [importlinter:contract:dataset-application-boundary] name = Dataset application services and contracts are framework and persistence neutral type = forbidden diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index c4171da78ab066..54473bf9a1fbf7 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -426,6 +426,19 @@ class EndpointConfig(BaseSettings): TRIGGER_URL: str = Field(description="Template url for triggers", default="http://localhost:5001") + NETWORK_ACCESS_API_URL: str = Field( + description="Optional internal SaaS API base URL for network access policy requests; " + "falls back to BILLING_API_URL", + default="", + ) + + NETWORK_ACCESS_TRUSTED_PROXY_CIDRS: str = Field( + description="Comma-separated trusted proxy CIDRs for network-access current-IP previews. " + "Only verified socket peers may supply X-Forwarded-For; empty disables Cloud IP previews. " + "Match the Gateway trust boundary and sanitize or append the client IP at the edge.", + default="", + ) + class FileAccessConfig(BaseSettings): """ diff --git a/api/controllers/API_SCHEMA_GUIDE.md b/api/controllers/API_SCHEMA_GUIDE.md index 6fb7926ea782d3..1ef002cfbfe7a7 100644 --- a/api/controllers/API_SCHEMA_GUIDE.md +++ b/api/controllers/API_SCHEMA_GUIDE.md @@ -224,6 +224,95 @@ return dump_response(WorkflowRunDetailResponse, workflow_run) Use manual `model_validate(...).model_dump(...)` only when the endpoint needs behavior that `dump_response(...)` does not provide, such as returning a non-dict payload, intentionally excluding fields, or composing a `(body, status)` tuple. +## Public access-error contracts + +IP-policy denials must be indistinguishable from each surface's native +authentication/not-found errors. The Gateway terminates denied requests before +execution; Core owns the corresponding native error baselines. Keep their JSON +bytes (sorted keys, compact separators, no trailing newline), status and +application-owned headers aligned. Do not add policy names, internal reasons, +`ip_access_denied`, or a policy-specific response header. + +| Surface | Error contract | +| --- | --- | +| Service API | 401 `unauthorized`, `Access token is invalid`; missing/malformed Bearer uses `Authorization header must be provided and start with 'Bearer'` | +| OAuth OpenAPI | Its own 401 `unauthorized` / `invalid_token`, not the Service API's token message | +| MCP | 404 JSON-RPC `-32600` / `Server Not Found`, echoing a string/number request ID or null | +| Workflow Webhook | 404 standard Werkzeug `NotFound()` HTML without capability ID/private detail; not JSON | +| Plugin callback | 404 JSON `{"error":"Endpoint not found"}`; not the Workflow Webhook HTML | +| WebApp | Canonical 404 `app_not_found` / `App not found.`, with optional trusted `client_ip` | +| HITL form / upload | Native form 404 / invalid-upload-token 403, not a WebApp error page | + +These errors use `Cache-Control: no-store`. JSON uses `Content-Type: +application/json`; Workflow Webhook uses `text/html; charset=utf-8`. Service API +401 also includes `WWW-Authenticate: Bearer realm="api"`; OpenAPI preserves its +native challenge/extra fields. For callers: if an API key is confirmed correct, +contact the App administrator to check whether the source IP is allowed. + +MCP resolves unavailable identity before validating an invocation. Its error-only +ID parser accepts at most 64 KiB of strict UTF-8 JSON, nesting depth 64; absent, +invalid or oversized IDs become null. Numeric lexemes retain precision. Allowed +requests retain their existing body and protocol-validation path. Do not create +EndUsers or execute tools just to construct a not-found response. + +MCP identity preflight uses a short read session that ends before body reads or +parsing. Normal execution rechecks the same server/App/tenant identity in its +own transaction and uses fresh ORM objects; deletion, disabling or rebinding +during parsing must not execute against the stale identity. Construct opaque +404 responses outside both sessions. The error ID reader reuses existing raw +Werkzeug body-cache bytes without changing that cache, and otherwise reads at +most the limit plus one sentinel byte; it must not use unbounded `get_data()` +for unknown-length bodies. This error-only limit does not cap valid MCP bodies. + +Real dependency failures stay fail-closed errors, not a missing-policy allow. +The Gateway can present an unresolved canonical WebApp reference as opaque404 +while still terminating the request; it must never forward it because the +read-side miss could be replication lag. Internal audit/metrics retain the true +policy-denied or resolution-failure reason even though the public response does +not disclose it. + +### WebApp and Console 404 metadata + +The Web and Console blueprints add optional request-local `client_ip` metadata +to their JSON app-not-found responses. This is transport error metadata, +not an App property, a new endpoint, or a field in successful response DTOs. + +- Public WebApp errors use only `code: app_not_found`, `message: App not found.`, + `status: 404` and optional `client_ip`. Strip exception-specific details that + would reveal whether an App exists. Authenticated Console management errors + preserve their original code/message/extra fields. +- A typed `app_not_found` 404 is eligible. Generic 404s are eligible only for + matched GET app-identity routes: Web bootstrap (`site`, `parameters`, `meta`, + `passport`, `login/status`, `webapp/access-mode`) and Console App/Agent, + installed-App detail/parameters/meta, and trial-App detail/parameters. +- The field is a single normalized IP resolved using the configured trusted + proxy boundary. Do not accept a caller's query/body IP or use unverified + forwarding headers. If trust configuration or resolution is unavailable, + omit `client_ip` instead of guessing a proxy address or changing the 404 to 503. +- These eligible errors use `Cache-Control: no-store`, because their IP metadata + must not be shared between callers through URL-based error caching. +- Known unavailable/unpublished WebApp identity errors use this same404; arbitrary + 400s, auth errors and dependency503s do not. Public login/status and access-mode + must validate a supplied App reference even when user authentication is disabled. +- HITL keeps its own canonical form/token error; no App IP is attached. Ordinary + resource404s, unmatched routes, streams and Inner API contracts are unchanged. + In particular, an authenticated Console trial403 is not converted to404. + +For example, either an unavailable Web passport or a Gateway WebApp denial is: + +```json +{ + "code": "app_not_found", + "message": "App not found.", + "status": 404, + "client_ip": "203.0.113.42" +} +``` + +The existing frontend error transport already accepts optional `client_ip`; +success OpenAPI/TypeScript/Zod/oRPC contracts do not change for this enrichment. +Add coverage at the final serialized-response boundary, including exceptions +with their own `data`, so Flask-RESTX cannot silently discard the metadata. ## Error Responses Every 4xx/5xx response is documented with the surface's error body: `ErrorResponse` (`code`, `message`, `status`, diff --git a/api/controllers/common/access_response.py b/api/controllers/common/access_response.py new file mode 100644 index 00000000000000..1d7e7630926643 --- /dev/null +++ b/api/controllers/common/access_response.py @@ -0,0 +1,152 @@ +"""Native, non-cacheable access errors shared with the public gateway. + +Keep these byte-level contracts local to their owning surfaces: Service API and +OpenAPI authentication, unavailable MCP identities, and missing trigger endpoints. +Successful responses and other APIs retain their existing serialization. +""" + +import json +from dataclasses import dataclass +from typing import Any + +from flask import Blueprint, Response, request +from werkzeug.exceptions import HTTPException, NotFound +from werkzeug.wrappers import Response as WerkzeugResponse + +MCP_ERROR_BODY_LIMIT = 64 * 1024 +MCP_ERROR_MAX_DEPTH = 64 +MISSING_BEARER_MESSAGE = "Authorization header must be provided and start with 'Bearer'" + + +def _json(value: Any) -> str: + # Match Go's non-HTML-escaping JSON encoder, including its JS-safe separators. + return ( + json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True, allow_nan=False) + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + ) + + +def register_auth_error_response(blueprint: Blueprint, *, www_authenticate: str | None = None) -> None: + """Canonicalize local final 401 bodies, preserving native message/extra fields. + + The Service API supplies its Bearer challenge explicitly. OpenAPI retains its + own challenge and OAuth error fields; other surfaces do not register this hook. + """ + + @blueprint.after_request + def auth_error_response(response: Response) -> Response: + if response.status_code != 401 or not response.is_json or response.is_streamed: + return response + payload = response.get_json(silent=True) + if not isinstance(payload, dict): + return response + response.set_data(_json(payload)) + response.headers["Content-Type"] = "application/json" + response.headers["Cache-Control"] = "no-store" + if www_authenticate is not None: + response.headers["WWW-Authenticate"] = www_authenticate + return response + + +@dataclass(frozen=True) +class _JSONNumber: + """An already-validated JSON number lexeme, without float/int conversion.""" + + value: str + + +def _within_json_depth(body: bytes) -> bool: + """Match the gateway's bounded parser; brackets inside strings do not count.""" + depth = 0 + in_string = False + escaped = False + for char in body: + if in_string: + if escaped: + escaped = False + elif char == 92: # backslash + escaped = True + elif char == 34: # quote + in_string = False + elif char == 34: + in_string = True + elif char in (91, 123): # array or object + depth += 1 + if depth > MCP_ERROR_MAX_DEPTH: + return False + elif char in (93, 125): + depth -= 1 + return True + + +def _invalid_constant(_value: str) -> None: + raise ValueError("Non-JSON numeric constant") + + +def _parse_mcp_error_request_id(body: bytes) -> str: + """Parse only an error response's ID, retaining its original numeric lexeme.""" + try: + if len(body) > MCP_ERROR_BODY_LIMIT or not _within_json_depth(body): + return "null" + payload = json.loads( + body.decode("utf-8"), + parse_int=_JSONNumber, + parse_float=_JSONNumber, + parse_constant=_invalid_constant, + ) + if not isinstance(payload, dict): + return "null" + request_id = payload.get("id") + if isinstance(request_id, _JSONNumber): + return request_id.value + if isinstance(request_id, str): + # Reject unpaired surrogates instead of creating invalid UTF-8. + request_id.encode("utf-8") + return _json(request_id) + except (ValueError, UnicodeError, RecursionError): + pass + return "null" + + +def _mcp_request_id() -> str: + """Read bounded raw bytes, including bodies already cached by request logging. + + Werkzeug's public get_data() reuses _cached_data but otherwise reads the + entire stream. Its stream property does not replay that cache. Keep this + read-only private-attribute compatibility here: it avoids unbounded reads + for unknown Content-Length and never installs a partial request cache. + """ + try: + if request.content_length is not None and request.content_length > MCP_ERROR_BODY_LIMIT: + return "null" + cached = vars(request).get("_cached_data") + if cached is not None: + return _parse_mcp_error_request_id(cached) if isinstance(cached, bytes) else "null" + body = request.stream.read(MCP_ERROR_BODY_LIMIT + 1) + except HTTPException: + return "null" + return _parse_mcp_error_request_id(body) + + +def mcp_server_not_found_response() -> Response: + """Opaque MCP lookup failure; no app/server identity or policy is disclosed.""" + body = '{"error":{"code":-32600,"message":"Server Not Found"},"id":' + _mcp_request_id() + ',"jsonrpc":"2.0"}' + return Response(body, status=404, content_type="application/json", headers={"Cache-Control": "no-store"}) + + +def webhook_not_found_response() -> WerkzeugResponse: + """Use Werkzeug's normal HTML 404 without caller IDs or internal detail.""" + response = NotFound().get_response() + response.headers["Cache-Control"] = "no-store" + return response + + +def plugin_endpoint_not_found_response() -> Response: + """Plugin callbacks own a JSON not-found shape, unlike native Webhooks.""" + return Response( + _json({"error": "Endpoint not found"}), + status=404, + content_type="application/json", + headers={"Cache-Control": "no-store"}, + ) diff --git a/api/controllers/common/app_access_error.py b/api/controllers/common/app_access_error.py new file mode 100644 index 00000000000000..052e46c042fa52 --- /dev/null +++ b/api/controllers/common/app_access_error.py @@ -0,0 +1,139 @@ +"""Keep public App failures indistinguishable from Gateway policy denials. + +This transport hook runs after Flask-RESTX has formatted errors, including errors +with their own ``data`` payload. Web App identity errors use a minimal canonical +404; Console keeps its existing error ownership and only receives IP metadata. +Unrelated resources and authentication failures retain their own contracts. +""" + +from collections.abc import Mapping +from contextlib import suppress +from typing import Literal + +from flask import Blueprint, Response, current_app, request + +from configs import dify_config +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError, resolve_network_access_client_ip + +_APP_IDENTITY_ROUTES = { + "web": frozenset({"/site", "/parameters", "/meta", "/passport", "/login/status", "/webapp/access-mode"}), + "console": frozenset( + { + "/apps/", + "/agent/", + "/installed-apps/", + "/installed-apps//parameters", + "/installed-apps//meta", + "/trial-apps/", + "/trial-apps//parameters", + } + ), +} + +_UNAVAILABLE_APP_CODES = frozenset({"app_unavailable", "agent_not_published"}) + + +def _canonical_json(response: Response, payload: Mapping[str, object]) -> Response: + """Match Gateway JSON bytes as well as its error status/header contract.""" + response.headers["Cache-Control"] = "no-store" + response.headers["Content-Type"] = "application/json" + response.set_data(current_app.json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)) + return response + + +def register_app_access_error_metadata(blueprint: Blueprint, *, surface: Literal["web", "console"]) -> None: + """Normalize Web App identity failures, without masking dependency errors. + + Use matched route rules instead of caller-supplied paths or IDs. A missing + app does not have an App model. HITL keeps its native form/token errors rather + than being reclassified as an App error. This hook is not registered on + Service, Inner, or other APIs. + """ + prefix = (blueprint.url_prefix or "").rstrip("/") + identity_rules = frozenset(prefix + route for route in _APP_IDENTITY_ROUTES[surface]) + + @blueprint.after_request + def add_app_access_error_metadata(response: Response) -> Response: + # Most responses cannot be changed by this hook. Avoid decoding successful + # payloads (including large application data) or unrelated error bodies. + status = response.status_code + if status != 404 and (surface != "web" or status not in (400, 403)): + return response + if response.is_streamed or response.direct_passthrough or not response.is_json or request.url_rule is None: + return response + + rule = request.url_rule.rule + is_app_identity = request.method == "GET" and rule in identity_rules + is_hitl = rule.startswith((prefix + "/form/human_input/", prefix + "/human-input-forms/")) + if is_hitl: + if surface != "web" or not ( + (status == 404 and rule.startswith(prefix + "/form/human_input/")) + or (status == 403 and rule == prefix + "/human-input-forms/files") + ): + return response + elif status != 404 and not (surface == "web" and status == 400 and is_app_identity): + return response + + # Non-HITL 404s still need inspection outside the identity allowlist: + # typed app_not_found errors can originate from invocation/resource routes. + payload = response.get_json(silent=True) + if not isinstance(payload, dict): + return response + # Preserve HITL's dedicated expiry/token UI and do not expose an App IP + # or policy-specific marker through these capability endpoints. + if is_hitl: + if ( + surface == "web" + and rule.startswith(prefix + "/form/human_input/") + and response.status_code == 404 + and payload.get("code") == "not_found" + ): + return _canonical_json(response, {"code": "not_found", "message": "Form not found", "status": 404}) + if ( + surface == "web" + and rule == prefix + "/human-input-forms/files" + and response.status_code == 403 + and payload.get("code") == "invalid_upload_token" + ): + return _canonical_json( + response, + {"code": "invalid_upload_token", "message": "Upload token is invalid or expired.", "status": 403}, + ) + return response + if ( + surface == "web" + and is_app_identity + and response.status_code == 400 + and payload.get("code") in _UNAVAILABLE_APP_CODES + ): + # A known unavailable/unpublished App is the same public state as a + # missing one. Never apply this to arbitrary 400s or dependency503s. + response.status_code = 404 + if response.status_code != 404: + return response + if payload.get("code") != "app_not_found" and not is_app_identity: + return response + + if surface == "web": + # Do not preserve exception-specific data that would disclose why + # an App is unavailable (or whether it exists) to an unauthenticated + # visitor. Console's authenticated management errors are unchanged. + payload = {"code": "app_not_found", "message": "App not found.", "status": 404} + + # An app 404 can be cached by URL, but its client address cannot be. + response.headers["Cache-Control"] = "no-store" + # This hook owns the metadata: never preserve an unverified address + # supplied by an existing payload when trusted resolution is unavailable. + payload.pop("client_ip", None) + trusted_proxy_cidrs = dify_config.NETWORK_ACCESS_TRUSTED_PROXY_CIDRS + # Never mislabel an internal Docker/ALB peer as the user's address when + # deployment trust configuration is missing. + if trusted_proxy_cidrs.strip(): + # Optional display metadata must not turn a genuine 404 into 503. + with suppress(NetworkAccessClientIPUnavailableError): + payload["client_ip"] = resolve_network_access_client_ip(request.environ, trusted_proxy_cidrs) + + if surface == "web": + return _canonical_json(response, payload) + response.set_data(current_app.json.dumps(payload)) + return response diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 06fb3bf1e07bd6..43ff40c5098f64 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -7,6 +7,7 @@ from flask import Blueprint, current_app, got_request_exception from flask_restx import Namespace +from controllers.common.app_access_error import register_app_access_error_metadata from controllers.common.errors import ( InternalServerError, InvalidArgumentError, @@ -26,6 +27,7 @@ ) bp = Blueprint("console", __name__, url_prefix="/console/api") +register_app_access_error_metadata(bp, surface="console") api = ExternalApi( bp, @@ -243,6 +245,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: members, model_providers, models, + network_access_group, plugin, rbac, skills, @@ -336,6 +339,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: members, model_providers, models, + network_access_group, plugin, rbac, skills, @@ -430,6 +434,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: members, model_providers, models, + network_access_group, plugin, rbac, skills, @@ -494,6 +499,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: "model_config", "model_providers", "models", + "network_access_group", "notification", "oauth", "oauth_server", diff --git a/api/controllers/console/workspace/network_access_group.py b/api/controllers/console/workspace/network_access_group.py new file mode 100644 index 00000000000000..f6bcd2cf041239 --- /dev/null +++ b/api/controllers/console/workspace/network_access_group.py @@ -0,0 +1,495 @@ +from datetime import datetime +from typing import Any, Self +from uuid import UUID + +from flask import Response, after_this_request, request +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, Field, ValidationError, model_validator +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, NotFound, ServiceUnavailable + +from configs import dify_config +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console import console_ns +from controllers.console.app.error import AppNotFoundError +from controllers.console.flask_admission import console_account_admission +from controllers.console.wraps import cloud_edition_billing_paid_plan_required, model_validate +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError, resolve_network_access_client_ip +from enums import DeploymentEdition +from extensions.ext_application_services import application_services +from fields.base import ResponseModel +from libs.external_api import error_responses +from libs.helper import dump_response +from machinery.context import RequestContext +from services.network_access_group_service import ( + NetworkAccessGroupAccessDeniedError, + NetworkAccessGroupAppNotFoundError, + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupError, + NetworkAccessGroupInvalidPolicyError, + NetworkAccessGroupInvalidResponseError, + NetworkAccessGroupUnsupportedAccessPointsError, + NetworkAccessGroupUnsupportedAppModeError, + NetworkAccessGroupUpstreamError, + NetworkAccessPoint, +) + + +class NetworkAccessGroupCreatePayload(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: str = Field(default="", max_length=500) + allowed_cidrs: list[str] = Field(min_length=1, max_length=100) + + +class NetworkAccessGroupUpdatePayload(NetworkAccessGroupCreatePayload): + expected_version: int = Field(ge=1) + + +class NetworkAccessGroupDeleteQuery(BaseModel): + expected_version: int = Field(ge=1, description="Current group version used for optimistic concurrency") + + +class AppNetworkAccessGroupUpdatePayload(BaseModel): + enabled: bool + group_id: UUID | None = Field( + validation_alias=AliasChoices("group_id", "groupId", "policy_id", "policyId"), + ) + access_points: list[NetworkAccessPoint] = Field( + max_length=4, + validation_alias=AliasChoices("access_points", "accessPoints"), + ) + expected_version: int = Field(ge=0) + + @model_validator(mode="after") + def validate_enabled_configuration(self) -> Self: + if len(set(self.access_points)) != len(self.access_points): + raise ValueError("access_points must not contain duplicates") + if self.enabled and self.group_id is None: + raise ValueError("group_id is required when network access control is enabled") + if self.enabled and not self.access_points: + raise ValueError("access_points must not be empty when network access control is enabled") + return self + + +class NetworkAccessGroupAppResponse(ResponseModel): + id: str + name: str + icon: str | None = None + icon_type: str | None = None + icon_background: str | None = None + mode: str + bound_agent_id: str | None = None + + +class NetworkAccessGroupResponse(ResponseModel): + id: str + tenant_id: str + name: str + description: str = "" + allowed_cidrs: list[str] = Field( + min_length=1, + max_length=100, + ) + version: int = Field(ge=1) + used_by_count: int = Field(ge=0) + enforcing_count: int = Field(ge=0) + app_ids: list[str] + apps: list[NetworkAccessGroupAppResponse] + updated_by_account_id: str | None = None + created_at: datetime + updated_at: datetime + + @model_validator(mode="after") + def validate_enforcing_count(self) -> Self: + if self.enforcing_count > self.used_by_count: + raise ValueError("enforcing_count must not exceed used_by_count") + return self + + +class NetworkAccessGroupListResponse(ResponseModel): + tenant_id: str + entitled: bool + groups: list[NetworkAccessGroupResponse] + + +class NetworkAccessGroupMutationResponse(ResponseModel): + group: NetworkAccessGroupResponse + + +class NetworkAccessGroupDeleteResponse(ResponseModel): + deleted: bool + + +class NetworkAccessGroupCurrentIPResponse(ResponseModel): + client_ip: str + + +class NetworkAccessGroupCurrentIPCheckResponse(ResponseModel): + client_ip: str + allowed: bool + policy_version: int = Field(ge=1) + + +class AppNetworkAccessGroupBindingResponse(ResponseModel): + id: str + tenant_id: str + app_id: str + enabled: bool + group_id: str | None = None + access_points: list[NetworkAccessPoint] = Field( + max_length=4, + ) + version: int = Field(ge=1) + updated_by_account_id: str | None = None + created_at: datetime + updated_at: datetime + + @model_validator(mode="after") + def validate_enabled_configuration(self) -> Self: + if len(set(self.access_points)) != len(self.access_points): + raise ValueError("access_points must not contain duplicates") + if self.enabled and self.group_id is None: + raise ValueError("group_id is required when network access control is enabled") + # A saved enabled binding can have no scopes supported by the App's + # current mode. Preserve its switch/reference; effective_enabled is false. + return self + + +class AppNetworkAccessGroupResponse(ResponseModel): + tenant_id: str + app_id: str + entitled: bool + effective_enabled: bool + available_access_points: list[NetworkAccessPoint] + binding: AppNetworkAccessGroupBindingResponse | None + + +class AppNetworkAccessGroupStatusResponse(ResponseModel): + entitled: bool + configured: bool + enabled: bool + covered_count: int = Field(ge=0) + available_count: int = Field(ge=0) + + +class AppNetworkAccessGroupMutationResponse(ResponseModel): + binding: AppNetworkAccessGroupBindingResponse + effective_enabled: bool + available_access_points: list[NetworkAccessPoint] + + +register_schema_models( + console_ns, + NetworkAccessGroupCreatePayload, + NetworkAccessGroupUpdatePayload, + AppNetworkAccessGroupUpdatePayload, +) +register_response_schema_models( + console_ns, + NetworkAccessGroupAppResponse, + NetworkAccessGroupResponse, + NetworkAccessGroupListResponse, + NetworkAccessGroupMutationResponse, + NetworkAccessGroupDeleteResponse, + NetworkAccessGroupCurrentIPResponse, + NetworkAccessGroupCurrentIPCheckResponse, + AppNetworkAccessGroupBindingResponse, + AppNetworkAccessGroupResponse, + AppNetworkAccessGroupStatusResponse, + AppNetworkAccessGroupMutationResponse, +) + + +def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception: + if exc.reason == "INVALID_SECRET_KEY": + return ServiceUnavailable("Network access group service authentication failed.") + if exc.status_code in (400, 422): + return BadRequest("Invalid network access group request.") + if exc.status_code == 401: + return ServiceUnavailable("Network access group service authentication failed.") + if exc.status_code == 403: + return Forbidden("Network access groups are not available for this workspace.") + if exc.status_code == 404: + return NotFound("Network access group was not found.") + if exc.status_code == 409: + message_by_reason = { + "NETWORK_ACCESS_VERSION_CONFLICT": "The network access resource changed. Refresh it and try again.", + "NETWORK_ACCESS_GROUP_NAME_CONFLICT": "An IP policy with this name already exists.", + "NETWORK_ACCESS_GROUP_LIMIT": "This workspace has reached the network access group limit.", + } + return Conflict(message_by_reason.get(exc.reason or "", "The network access resource is in conflict.")) + if exc.status_code >= 500: + return ServiceUnavailable("Network access group service is unavailable.") + return BadGateway("Unexpected response from the network access group service.") + + +def _translate_service_error(exc: NetworkAccessGroupError) -> Exception: + if isinstance(exc, NetworkAccessGroupInvalidResponseError): + return BadGateway("Invalid response from the network access group service.") + if isinstance(exc, NetworkAccessGroupUpstreamError): + return _translate_upstream_error(exc) + if isinstance(exc, NetworkAccessGroupAccessDeniedError): + return Forbidden("Your workspace role does not allow this network access operation.") + if isinstance(exc, NetworkAccessGroupEntitlementUnavailableError): + return ServiceUnavailable("Billing entitlement is temporarily unavailable.") + if isinstance(exc, NetworkAccessGroupAppNotFoundError): + return AppNotFoundError() + if isinstance(exc, NetworkAccessGroupUnsupportedAppModeError): + return BadRequest(f"Network access control is not supported for app mode '{exc.app_mode}'.") + if isinstance(exc, NetworkAccessGroupUnsupportedAccessPointsError): + return BadRequest(f"Unsupported access points for this app: {', '.join(exc.access_points)}.") + if isinstance(exc, NetworkAccessGroupInvalidPolicyError): + return ServiceUnavailable("Network access policy cannot be evaluated.") + raise TypeError(f"Unsupported network access group error: {type(exc).__name__}") + + +def _serialize_response(response_model: type[ResponseModel], payload: object) -> dict[str, Any]: + try: + return dump_response(response_model, payload) + except ValidationError as exc: + raise BadGateway("Invalid response from the network access group service.") from exc + + +def _resolve_current_network_access_client_ip() -> str: + trusted_proxy_cidrs = dify_config.NETWORK_ACCESS_TRUSTED_PROXY_CIDRS + if not trusted_proxy_cidrs.strip(): + # This Cloud-only UI must never label an internal Docker/ALB socket peer + # as the user's address when deployment trust configuration is missing. + raise NetworkAccessClientIPUnavailableError("network access trusted proxies are not configured") + return resolve_network_access_client_ip(request.environ, trusted_proxy_cidrs) + + +@console_ns.route("/workspaces/current/network-access-groups") +class CurrentWorkspaceNetworkAccessGroupsApi(Resource): + @console_ns.response( + 200, + "Workspace network access groups retrieved successfully", + console_ns.models[NetworkAccessGroupListResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.list_groups(request_context) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupListResponse, payload) + + @console_ns.expect(console_ns.models[NetworkAccessGroupCreatePayload.__name__]) + @console_ns.response( + 201, + "Workspace network access group created successfully", + console_ns.models[NetworkAccessGroupMutationResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @cloud_edition_billing_paid_plan_required + @model_validate(NetworkAccessGroupCreatePayload) + def post( + self, + req_data: NetworkAccessGroupCreatePayload, + request_context: RequestContext, + ) -> tuple[dict[str, Any], int]: + try: + payload = application_services().network_access_groups.create_group( + request_context, + name=req_data.name, + description=req_data.description, + allowed_cidrs=req_data.allowed_cidrs, + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupMutationResponse, {"group": payload}), 201 + + +@console_ns.route("/workspaces/current/network-access-groups/") +class CurrentWorkspaceNetworkAccessGroupApi(Resource): + @console_ns.response( + 200, + "Workspace network access group retrieved successfully", + console_ns.models[NetworkAccessGroupMutationResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, group_id: UUID) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.get_group( + request_context, + group_id=str(group_id), + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupMutationResponse, {"group": payload}) + + @console_ns.expect(console_ns.models[NetworkAccessGroupUpdatePayload.__name__]) + @console_ns.response( + 200, + "Workspace network access group updated successfully", + console_ns.models[NetworkAccessGroupMutationResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @cloud_edition_billing_paid_plan_required + @model_validate(NetworkAccessGroupUpdatePayload) + def put( + self, + req_data: NetworkAccessGroupUpdatePayload, + request_context: RequestContext, + group_id: UUID, + ) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.update_group( + request_context, + group_id=str(group_id), + name=req_data.name, + description=req_data.description, + allowed_cidrs=req_data.allowed_cidrs, + expected_version=req_data.expected_version, + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupMutationResponse, {"group": payload}) + + @console_ns.doc(params=query_params_from_model(NetworkAccessGroupDeleteQuery)) + @console_ns.response( + 200, + "Workspace network access group deleted successfully", + console_ns.models[NetworkAccessGroupDeleteResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @cloud_edition_billing_paid_plan_required + @model_validate(NetworkAccessGroupDeleteQuery) + def delete( + self, + req_data: NetworkAccessGroupDeleteQuery, + request_context: RequestContext, + group_id: UUID, + ) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.delete_group( + request_context, + group_id=str(group_id), + expected_version=req_data.expected_version, + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupDeleteResponse, {"deleted": payload}) + + +@console_ns.route("/workspaces/current/network-access-groups/current-ip") +class CurrentWorkspaceNetworkAccessGroupCurrentIPApi(Resource): + @console_ns.response( + 200, + "Trusted current client IP retrieved without requiring an existing network access group", + console_ns.models[NetworkAccessGroupCurrentIPResponse.__name__], + ) + @console_ns.response(403, "The workspace role does not allow network access policy reads") + @console_ns.response(503, "A trusted current client IP is unavailable") + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext) -> dict[str, Any]: + @after_this_request + def disable_client_ip_response_cache(response: Response) -> Response: + response.headers["Cache-Control"] = "no-store" + return response + + try: + payload = application_services().network_access_groups.get_current_ip( + request_context, + client_ip_supplier=_resolve_current_network_access_client_ip, + ) + except NetworkAccessClientIPUnavailableError as exc: + raise ServiceUnavailable("Current client IP is unavailable.") from exc + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupCurrentIPResponse, {"client_ip": payload}) + + +@console_ns.route("/workspaces/current/network-access-groups//check-current-ip") +class CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi(Resource): + @console_ns.response( + 200, + "Current client IP checked against the workspace network access group", + console_ns.models[NetworkAccessGroupCurrentIPCheckResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, group_id: UUID) -> dict[str, Any]: + @after_this_request + def disable_client_ip_response_cache(response: Response) -> Response: + response.headers["Cache-Control"] = "no-store" + return response + + try: + payload = application_services().network_access_groups.check_current_ip( + request_context, + group_id=str(group_id), + client_ip_supplier=_resolve_current_network_access_client_ip, + ) + except NetworkAccessClientIPUnavailableError as exc: + raise ServiceUnavailable("Current client IP is unavailable.") from exc + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(NetworkAccessGroupCurrentIPCheckResponse, payload) + + +@console_ns.route("/apps//network-access-group") +class AppNetworkAccessGroupApi(Resource): + @error_responses(AppNotFoundError) + @console_ns.response( + 200, + "App network access group binding retrieved successfully", + console_ns.models[AppNetworkAccessGroupResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, app_id: UUID) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.get_app_binding( + request_context, + app_id=str(app_id), + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(AppNetworkAccessGroupResponse, payload) + + @error_responses(AppNotFoundError) + @console_ns.expect(console_ns.models[AppNetworkAccessGroupUpdatePayload.__name__]) + @console_ns.response( + 200, + "App network access group binding updated successfully", + console_ns.models[AppNetworkAccessGroupMutationResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @cloud_edition_billing_paid_plan_required + @model_validate(AppNetworkAccessGroupUpdatePayload) + def put( + self, + req_data: AppNetworkAccessGroupUpdatePayload, + request_context: RequestContext, + app_id: UUID, + ) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.update_app_binding( + request_context, + app_id=str(app_id), + enabled=req_data.enabled, + group_id=str(req_data.group_id) if req_data.group_id is not None else None, + access_points=list(req_data.access_points), + expected_version=req_data.expected_version, + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(AppNetworkAccessGroupMutationResponse, payload) + + +@console_ns.route("/apps//network-access-group/status") +class AppNetworkAccessGroupStatusApi(Resource): + @error_responses(AppNotFoundError) + @console_ns.response( + 200, + "App network access control entry status retrieved successfully", + console_ns.models[AppNetworkAccessGroupStatusResponse.__name__], + ) + @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + def get(self, request_context: RequestContext, app_id: UUID) -> dict[str, Any]: + try: + payload = application_services().network_access_groups.get_app_status( + request_context, + app_id=str(app_id), + ) + except NetworkAccessGroupError as exc: + raise _translate_service_error(exc) from exc + return _serialize_response(AppNetworkAccessGroupStatusResponse, payload) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 55f9ed1b4b9dd6..589afaf80390ca 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -5,10 +5,11 @@ from functools import wraps from typing import Any, Concatenate, Protocol, cast, overload +import httpx from flask import abort, request from pydantic import BaseModel, ValidationError from sqlalchemy import select -from werkzeug.exceptions import Forbidden, UnprocessableEntity +from werkzeug.exceptions import Forbidden, ServiceUnavailable, UnprocessableEntity from configs import dify_config from controllers.common.rbac import RBACPermission, RBACResourceScope @@ -25,7 +26,7 @@ from models.account import AccountStatus from models.dataset import RateLimitLog from models.model import DifySetup -from services.billing_service import BillingService +from services.billing_service import BillingService, _BillingHTTPStatusError from services.entities.feature_entities import LicenseStatus from services.feature_service import FeatureService from services.operation_service import OperationService, UtmInfo @@ -158,15 +159,35 @@ def decorated(*args: P.args, **kwargs: P.kwargs): return decorated +def is_cloud_edition_billing_paid_plan(tenant_id: str) -> bool: + """Return whether a Cloud workspace has a paid subscription. + + Keep this predicate shared with the mutation decorator so read endpoints can + expose an entitlement flag without rejecting Sandbox workspaces that need to + render an upgrade state. + """ + + try: + billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) + except ( + _BillingHTTPStatusError, + httpx.RequestError, + json.JSONDecodeError, + UnicodeDecodeError, + ValidationError, + ) as exc: + raise ServiceUnavailable("Billing entitlement is temporarily unavailable.") from exc + return billing_info["subscription"]["plan"] in ( + CloudPlan.PROFESSIONAL, + CloudPlan.TEAM, + ) + + def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): _, current_tenant_id = current_account_with_tenant() - billing_info = BillingService.get_info(current_tenant_id, exclude_vector_space=True) - if billing_info["subscription"]["plan"] not in ( - CloudPlan.PROFESSIONAL, - CloudPlan.TEAM, - ): + if not is_cloud_edition_billing_paid_plan(current_tenant_id): abort(403, "This feature requires a paid plan.") return view(*args, **kwargs) diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index 3fc1492003d7d3..54e518ac525464 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -6,11 +6,12 @@ from sqlalchemy import select from sqlalchemy.orm import Session +from controllers.common.access_response import mcp_server_not_found_response from controllers.common.schema import register_response_schema_models, register_schema_model from controllers.mcp import mcp_ns +from core.db.session_factory import session_factory from core.mcp import types as mcp_types from core.mcp.server.streamable_http import handle_mcp_request, negotiate_protocol_version -from extensions.ext_database import db from fields.base import ResponseModel from graphon.variables.input_entities import VariableEntity, VariableEntityType from libs import helper @@ -35,6 +36,13 @@ def __init__(self, error_code: int, message: str): super().__init__(message) +class MCPServerNotFoundError(MCPRequestError): + """An unavailable server, backing app, or inactive server is one opaque identity failure.""" + + def __init__(self): + super().__init__(mcp_types.INVALID_REQUEST, "Server Not Found") + + class MCPRequestPayload(BaseModel): jsonrpc: str = Field(description="JSON-RPC version (should be '2.0')") method: str = Field(description="The method to invoke") @@ -44,7 +52,7 @@ class MCPRequestPayload(BaseModel): class MCPJSONRPCErrorResponse(ResponseModel): jsonrpc: Literal["2.0"] - id: int | str | None + id: int | float | str | None error: mcp_types.ErrorData @@ -58,14 +66,14 @@ class MCPJSONRPCResponse(RootModel[mcp_types.JSONRPCResponse | MCPJSONRPCErrorRe @mcp_ns.route("/server//mcp") class MCPAppApi(Resource): - @mcp_ns.expect(mcp_ns.models[MCPRequestPayload.__name__]) + @mcp_ns.expect(mcp_ns.models[MCPRequestPayload.__name__], validate=False) @mcp_ns.doc("handle_mcp_request") @mcp_ns.doc(description="Handle Model Context Protocol (MCP) requests for a specific server") @mcp_ns.doc(params={"server_code": "Unique identifier for the MCP server"}) @mcp_ns.response(200, "MCP JSON-RPC response", mcp_ns.models[MCPJSONRPCResponse.__name__]) @mcp_ns.response(202, "MCP notification accepted") @mcp_ns.response(400, "Invalid MCP request or parameters", mcp_ns.models[MCPJSONRPCErrorResponse.__name__]) - @mcp_ns.response(404, "Server or app not found") + @mcp_ns.response(404, "Server or app not found", mcp_ns.models[MCPJSONRPCErrorResponse.__name__]) def post(self, server_code: str): """Handle MCP requests for a specific server. @@ -82,6 +90,21 @@ def post(self, server_code: str): ValidationError: Invalid request format or parameters """ # response-contract:ignore MCP route returns Flask Response from JSON-RPC handler + sessions = session_factory.get_session_maker() + try: + # Resolve identity before parsing the body: missing identities have + # one bounded response even for malformed or oversized JSON. Only + # scalar IDs cross this short read session, never its ORM objects. + with sessions.begin() as session: + mcp_server, app = self._get_mcp_server_and_app(server_code, session) + self._validate_server_status(mcp_server) + identity = (mcp_server.id, app.id, app.tenant_id) + except MCPServerNotFoundError: + # Error formatting may read the request body; release DB resources first. + return mcp_server_not_found_response() + + # Allowed requests retain the existing full body/protocol parser. The + # 64 KiB cap belongs only to opaque errors, not normal MCP invocations. args = MCPRequestPayload.model_validate(mcp_ns.payload or {}) request_id: int | str | None = args.id try: @@ -100,31 +123,31 @@ def post(self, server_code: str): response.status_code = 400 return response - # Resolve the negotiated protocol version from the MCP-Protocol-Version header. is_initialize = isinstance(mcp_request.root, mcp_types.InitializeRequest) header_value = request.headers.get("MCP-Protocol-Version") protocol_version = negotiate_protocol_version(header_value, is_initialize) if protocol_version is None: - # A notification never receives a response, even with an unsupported header. if isinstance(mcp_request, mcp_types.ClientNotification): protocol_version = mcp_types.DEFAULT_NEGOTIATED_VERSION else: return self._protocol_version_error_response(request_id, header_value) - with Session(db.engine, expire_on_commit=False) as session: - # Get MCP server and app - mcp_server, app = self._get_mcp_server_and_app(server_code, session) - self._validate_server_status(mcp_server) - - # Get user input form - user_input_form = self._get_user_input_form(app, session) - - # Handle notification vs request differently - response = self._process_mcp_message( - mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version - ) - session.commit() - return response + try: + # Reading/parsing may have taken time. Revalidate in the original + # execution transaction and use fresh, attached ORM records. A code + # reassigned to a different server/App/tenant is not this request's identity. + with sessions.begin() as session: + mcp_server, app = self._get_mcp_server_and_app(server_code, session) + self._validate_server_status(mcp_server) + if (mcp_server.id, app.id, app.tenant_id) != identity: + raise MCPServerNotFoundError() + user_input_form = self._get_user_input_form(app, session=session) + + return self._process_mcp_message( + mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version + ) + except MCPServerNotFoundError: + return mcp_server_not_found_response() def _protocol_version_error_response(self, request_id: int | str | None, header_value: str | None) -> Response: """Return a JSON-RPC error for an unsupported MCP-Protocol-Version header. @@ -147,18 +170,20 @@ def _get_mcp_server_and_app(self, server_code: str, session: Session) -> tuple[A """Get and validate MCP server and app in one query session""" mcp_server = session.scalar(select(AppMCPServer).where(AppMCPServer.server_code == server_code).limit(1)) if not mcp_server: - raise MCPRequestError(mcp_types.INVALID_REQUEST, "Server Not Found") + raise MCPServerNotFoundError() - app = session.scalar(select(App).where(App.id == mcp_server.app_id).limit(1)) + app = session.scalar( + select(App).where(App.id == mcp_server.app_id, App.tenant_id == mcp_server.tenant_id).limit(1) + ) if not app: - raise MCPRequestError(mcp_types.INVALID_REQUEST, "App Not Found") + raise MCPServerNotFoundError() return mcp_server, app def _validate_server_status(self, mcp_server: AppMCPServer): """Validate MCP server status""" - if mcp_server.status != AppMCPServerStatus.ACTIVE.value: - raise MCPRequestError(mcp_types.INVALID_REQUEST, "Server is not active") + if mcp_server.status != AppMCPServerStatus.ACTIVE: + raise MCPServerNotFoundError() def _process_mcp_message( self, @@ -209,17 +234,17 @@ def _handle_request( return helper.compact_generate_response(result.model_dump(by_alias=True, mode="json", exclude_none=True)) - def _get_user_input_form(self, app: App, session: Session) -> list[VariableEntity]: + def _get_user_input_form(self, app: App, *, session: Session) -> list[VariableEntity]: """Get and convert user input form""" # Get raw user input form based on app mode if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app.workflow_with_session(session=session) - if not workflow: + if workflow is None: raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable") raw_user_input_form = workflow.user_input_form(to_old_structure=True) else: app_model_config = app.app_model_config_with_session(session=session) - if not app_model_config: + if app_model_config is None: raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable") raw_user_input_form = app_model_config.user_input_form_list diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 4b1e56fcdad91a..5e156f65dc65d4 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -4,6 +4,8 @@ from flask import Blueprint from flask_restx import Namespace +from controllers.common.access_response import register_auth_error_response + # Register response/query models BEFORE importing controller modules so that # @openapi_ns.response / @openapi_ns.expect decorators can resolve model names. from controllers.common.fields import EventStreamResponse, SimpleResultResponse @@ -161,6 +163,7 @@ bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1") attach_anti_framing(bp) +register_auth_error_response(bp) attach_catalog(bp) diff --git a/api/controllers/openapi/_app_settings.py b/api/controllers/openapi/_app_settings.py index 1278b0d6a64bb2..9228d7b32dfcd7 100644 --- a/api/controllers/openapi/_app_settings.py +++ b/api/controllers/openapi/_app_settings.py @@ -207,7 +207,7 @@ def reset_webapp(ctx: Context) -> WebAppToken: def webapp_access(ctx: Context) -> WebAppAccess: try: - access_mode = application_services().webapp_access.get_access_mode(app_id=ctx.app.id, app_code=None) + access_mode = application_services().webapp_access.get_configured_access_mode(app_id=ctx.app.id) except WebAppAccessUnavailableError as error: raise WebAppAccessUnavailable() from error return WebAppAccess(access_mode=access_mode) diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index 2816664909fb36..52f72a21afc14d 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -3,6 +3,7 @@ from flask import Blueprint from flask_restx import Namespace +from controllers.common.access_response import register_auth_error_response from controllers.common.errors import ( InternalServerError, InvalidArgumentError, @@ -13,6 +14,7 @@ from libs.external_api import ExternalApi bp = Blueprint("service_api", __name__, url_prefix="/v1") +register_auth_error_response(bp, www_authenticate='Bearer realm="api"') api = ExternalApi( bp, diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 4e462b39adcbf4..1546f59475af7b 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -16,6 +16,7 @@ from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized from configs import dify_config +from controllers.common.access_response import MISSING_BEARER_MESSAGE from controllers.common.resource_access_token_errors import resource_access_token_errors from controllers.service_api.schema import ( USER_FETCH_FROM_ATTR, @@ -445,24 +446,20 @@ def validate_and_get_api_token(scope: str | None = None): def extract_service_api_bearer_token() -> str: - auth_header = request.headers.get("Authorization") - if auth_header is None or " " not in auth_header: - raise Unauthorized("Authorization header must be provided and start with 'Bearer'") - - auth_scheme, auth_token = auth_header.split(None, 1) - if auth_scheme.lower() != "bearer": - raise Unauthorized("Authorization scheme must be 'Bearer'") + auth_token = peek_service_api_bearer_token() + if auth_token is None: + raise Unauthorized(MISSING_BEARER_MESSAGE) return auth_token def peek_service_api_bearer_token() -> str | None: - auth_header = request.headers.get("Authorization") - if auth_header is None or " " not in auth_header: - return None - auth_scheme, auth_token = auth_header.split(None, 1) - if auth_scheme.lower() != "bearer": + # Match the Gateway's HTTP SP/HTAB parser for both API keys and resource + # access tokens. Unicode whitespace must not bypass policy identity lookup. + auth_header = request.headers.get("Authorization") or "" + auth_parts = [part for part in auth_header.replace("\t", " ").split(" ") if part] + if len(auth_parts) != 2 or auth_parts[0].lower() != "bearer": return None - return auth_token + return auth_parts[1] class DatasetApiResource(Resource): diff --git a/api/controllers/trigger/trigger.py b/api/controllers/trigger/trigger.py index 203bfdf895d5de..26197d8411276e 100644 --- a/api/controllers/trigger/trigger.py +++ b/api/controllers/trigger/trigger.py @@ -2,8 +2,8 @@ import re from flask import jsonify, request -from werkzeug.exceptions import NotFound +from controllers.common.access_response import plugin_endpoint_not_found_response from controllers.trigger import bp from services.trigger.trigger_service import TriggerService from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService @@ -21,7 +21,7 @@ def trigger_endpoint(endpoint_id: str): """ # endpoint_id must be UUID if not UUID_MATCHER.match(endpoint_id): - raise NotFound("Invalid endpoint ID") + return plugin_endpoint_not_found_response() handling_chain = [ TriggerService.process_endpoint, TriggerSubscriptionBuilderService.process_builder_validation_endpoint, @@ -34,7 +34,7 @@ def trigger_endpoint(endpoint_id: str): break if not response: logger.info("Endpoint not found for %s", endpoint_id) - return jsonify({"error": "Endpoint not found"}), 404 + return plugin_endpoint_not_found_response() return response except ValueError as e: return jsonify({"error": "Endpoint processing failed", "message": str(e)}), 400 diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index 88fd70b2981fa9..4df3d1ac9d0867 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -2,8 +2,9 @@ import time from flask import jsonify, request -from werkzeug.exceptions import NotFound, RequestEntityTooLarge +from werkzeug.exceptions import RequestEntityTooLarge +from controllers.common.access_response import webhook_not_found_response from controllers.trigger import bp from core.trigger.debug.event_bus import TriggerDebugEventBus from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key @@ -89,8 +90,8 @@ def handle_webhook(webhook_id: str): "message": _get_quota_exceeded_message(error.feature), } ), 429 - except ValueError as error: - raise NotFound(str(error)) from error + except ValueError: + return webhook_not_found_response() except RequestEntityTooLarge: raise except Exception as e: @@ -160,8 +161,8 @@ def handle_webhook_debug(webhook_id: str): response_data, status_code = WebhookService.generate_webhook_response(node_config) return jsonify(response_data), status_code - except ValueError as e: - raise NotFound(str(e)) from e + except ValueError: + return webhook_not_found_response() except RequestEntityTooLarge: raise except Exception: diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py index 5e68e5c0b63a54..bc10b2da90eb48 100644 --- a/api/controllers/web/__init__.py +++ b/api/controllers/web/__init__.py @@ -3,6 +3,7 @@ from flask import Blueprint from flask_restx import Namespace +from controllers.common.app_access_error import register_app_access_error_metadata from controllers.common.errors import InternalServerError, NotFoundError, UnauthorizedError from controllers.console.error import NotInitValidateError, NotSetupError from controllers.web.error import ( @@ -14,6 +15,7 @@ from libs.external_api import ExternalApi bp = Blueprint("web", __name__, url_prefix="/api") +register_app_access_error_metadata(bp, surface="web") api = ExternalApi( bp, diff --git a/api/controllers/web/human_input_file_upload.py b/api/controllers/web/human_input_file_upload.py index 3966f9fee3905e..75f045488d0b48 100644 --- a/api/controllers/web/human_input_file_upload.py +++ b/api/controllers/web/human_input_file_upload.py @@ -91,7 +91,9 @@ def _extract_hitl_upload_token() -> str: if authorization is None: raise InvalidUploadTokenUnauthorizedError() - parts = authorization.split() + # Keep capability identity parsing identical to the Gateway's HTTP OWS + # boundary; Unicode/control separators must not bypass IP enforcement. + parts = [part for part in authorization.replace("\t", " ").split(" ") if part] if len(parts) != 2: raise InvalidUploadTokenUnauthorizedError() diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index fb334af4d1f8be..2ab3f7d7edc2e0 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -134,6 +134,10 @@ class LoginStatusApi(Resource): @web_ns.doc(params=query_params_from_model(LoginStatusQuery)) @web_ns.doc( responses={ + 200: "Login status", + 401: "Login status", + 404: "App not found", + 503: "Web app access service unavailable", HTTPStatus.OK: "Login status", HTTPStatus.UNAUTHORIZED: "Login status", } diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 7b2afe16bccefe..dce26a3f985495 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -1,11 +1,11 @@ from typing import Any, Self from pydantic import AliasChoices, Field -from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.schema import register_response_schema_models from controllers.web import web_ns +from controllers.web.error import WebAppNotFoundError from controllers.web.wraps import WebApiResource from enums import DeploymentEdition from extensions.ext_application_services import application_services @@ -145,7 +145,7 @@ def get(self, app_model: App, end_user: EndUser): try: bootstrap = application_services().web_app_runtime.get_bootstrap(app_model.id) except WebAppRuntimeUnavailableError: - raise Forbidden() from None + raise WebAppNotFoundError() from None return dump_response( WebAppSiteResponse, diff --git a/api/controllers/web/wraps.py b/api/controllers/web/wraps.py index 4edc5ac96f0304..4f948fa0087796 100644 --- a/api/controllers/web/wraps.py +++ b/api/controllers/web/wraps.py @@ -5,8 +5,7 @@ from flask import request from flask_restx import Resource -from sqlalchemy import select -from werkzeug.exceptions import BadRequest, NotFound, Unauthorized +from werkzeug.exceptions import NotFound, Unauthorized from constants import HEADER_NAME_APP_CODE from controllers.web.error import ( @@ -15,12 +14,12 @@ WebAppAuthRequiredError, WebAppNotFoundError, ) -from core.db.session_factory import session_factory from core.logging.context import set_identity_context from extensions.ext_application_services import application_services from libs.passport import PassportService from libs.token import extract_webapp_passport -from models.model import App, EndUser, Site +from models.model import App, EndUser +from repositories.web_passport_repository import get_web_passport_identity from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings from services.system_feature_service import SystemFeatureService from services.web_passport_gateways import resolve_web_app_auth_type @@ -68,24 +67,18 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> raise Unauthorized("App token is missing.") decoded = PassportService().verify(tk) app_code = decoded.get("app_code") + if not isinstance(app_code, str) or not app_code: + raise WebAppNotFoundError() app_id = decoded.get("app_id") - with session_factory.create_session() as session: - app_model = session.scalar(select(App).where(App.id == app_id)) - site = session.scalar(select(Site).where(Site.code == app_code)) - if not app_model: - raise NotFound() - if not app_code or not site: - raise BadRequest("Site URL is no longer valid.") - if app_model.enable_site is False: - raise BadRequest("Site is disabled.") - end_user_id = decoded.get("end_user_id") - end_user = session.scalar(select(EndUser).where(EndUser.id == end_user_id)) - if not end_user: - raise NotFound() - - # Validate user_id against end_user's session_id if provided - if user_id is not None and end_user.session_id != user_id: - raise Unauthorized("Authentication has expired.") + app_model, end_user = get_web_passport_identity(app_id, app_code, decoded.get("end_user_id")) + if app_model is None: + raise WebAppNotFoundError() + if end_user is None: + raise NotFound() + + # Validate user_id against end_user's session_id if provided. + if user_id is not None and end_user.session_id != user_id: + raise Unauthorized("Authentication has expired.") # for enterprise webapp auth app_web_auth_enabled = False diff --git a/api/core/app/public_runtime.py b/api/core/app/public_runtime.py new file mode 100644 index 00000000000000..5f1d16cba9a25b --- /dev/null +++ b/api/core/app/public_runtime.py @@ -0,0 +1,66 @@ +"""Publication admission shared by public Web App identity and passport reads. + +This is not a Console/Debug visibility rule. Published pointers keep serving +while a newer draft is edited; an Agent's dirty-draft flag is not a publication +gate. Every referenced artifact must still belong to this App and workspace. +""" + +from sqlalchemy import and_, or_, select +from sqlalchemy.sql.elements import ColumnElement + +from core.agent.publish_visibility import workflow_callable_active_snapshot_filter +from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus +from models.model import App, AppMode, AppModelConfig +from models.workflow import Workflow + + +def published_app_filter() -> ColumnElement[bool]: + """Match Apps with a live published artifact, without loading private content. + + Callers additionally own App/Site lifecycle and authentication checks. The + predicate performs only reads and can be reused inside passport issuance's + bounded transaction before creating or reusing an EndUser. + """ + published_workflow = ( + select(Workflow.id) + .where( + Workflow.id == App.workflow_id, + Workflow.app_id == App.id, + Workflow.tenant_id == App.tenant_id, + Workflow.version != "draft", + ) + .correlate(App) + .exists() + ) + published_model_config = ( + select(AppModelConfig.id) + .where(AppModelConfig.id == App.app_model_config_id, AppModelConfig.app_id == App.id) + .correlate(App) + .exists() + ) + published_agent = ( + select(Agent.id) + .join( + AgentConfigSnapshot, + and_( + AgentConfigSnapshot.id == Agent.active_config_snapshot_id, + AgentConfigSnapshot.agent_id == Agent.id, + AgentConfigSnapshot.tenant_id == Agent.tenant_id, + ), + ) + .where( + Agent.app_id == App.id, + Agent.tenant_id == App.tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + workflow_callable_active_snapshot_filter(), + ) + .correlate(App) + .exists() + ) + return or_( + and_(App.mode.in_((AppMode.WORKFLOW, AppMode.ADVANCED_CHAT)), published_workflow), + and_(App.mode.in_((AppMode.CHAT, AppMode.COMPLETION, AppMode.AGENT_CHAT)), published_model_config), + and_(App.mode == AppMode.AGENT, published_agent), + ) diff --git a/api/core/network_access/__init__.py b/api/core/network_access/__init__.py new file mode 100644 index 00000000000000..2aedd5c9338e68 --- /dev/null +++ b/api/core/network_access/__init__.py @@ -0,0 +1 @@ +"""Network-access policy helpers shared by the Console control plane.""" diff --git a/api/core/network_access/client_ip.py b/api/core/network_access/client_ip.py new file mode 100644 index 00000000000000..b706e42a098a15 --- /dev/null +++ b/api/core/network_access/client_ip.py @@ -0,0 +1,95 @@ +"""Resolve a preview IP from a verified immediate peer and a strict proxy chain. + +This is intentionally independent of the historical login-IP helper. Deployers +must configure the same trusted proxy boundary as the data-plane Gateway. An +edge proxy must sanitize X-Forwarded-For or append its verified immediate peer; +an untrusted caller cannot choose its IP merely by supplying forwarding headers. +""" + +import ipaddress +from collections.abc import Mapping +from functools import lru_cache + +type IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address +type IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network + +MAX_PROXY_HOPS = 32 +MAX_FORWARDED_HEADER_LENGTH = 2048 + + +class NetworkAccessClientIPUnavailableError(ValueError): + """A reliable current client address cannot be determined.""" + + +def _address(value: object) -> IPAddress: + if not isinstance(value, str) or not value.strip() or "%" in value: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + try: + address = ipaddress.ip_address(value.strip()) + except ValueError as exc: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") from exc + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped + return address + + +@lru_cache(maxsize=16) +def _trusted_networks(config: str) -> tuple[IPNetwork, ...]: + if not config.strip(): + return () + entries = config.split(",") + if len(entries) > 256: + raise NetworkAccessClientIPUnavailableError("invalid network-access proxy configuration") + networks: list[IPNetwork] = [] + for entry in entries: + try: + # Prefixes are explicit; reject host bits, scoped addresses and + # empty entries rather than broadening a malformed trust boundary. + if not entry.strip() or "%" in entry or "/" not in entry: + raise ValueError("expected a proxy CIDR") + network = ipaddress.ip_network(entry.strip(), strict=True) + if isinstance(network, ipaddress.IPv6Network) and network.network_address.ipv4_mapped is not None: + if network.prefixlen < 96: + raise ValueError("invalid mapped proxy CIDR") + network = ipaddress.IPv4Network((network.network_address.ipv4_mapped, network.prefixlen - 96)) + except ValueError as exc: + raise NetworkAccessClientIPUnavailableError("invalid network-access proxy configuration") from exc + networks.append(network) + return tuple(networks) + + +def resolve_network_access_client_ip(environ: Mapping[str, object], trusted_proxy_cidrs: str) -> str: + """Return the first non-trusted hop, never a browser-supplied IP argument. + + If the actual socket peer is not trusted (including an empty trust list), + ignore all forwarding headers. If it is trusted, require a complete, bounded + XFF chain and strip trusted hops from the right. Invalid or all-trusted chains + are unavailable rather than silently returning a proxy's address. Preserve + the original peer when Werkzeug ProxyFix has rewritten REMOTE_ADDR. + """ + original = environ.get("werkzeug.proxy_fix.orig") + if original is not None: + if not isinstance(original, Mapping): + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + peer_value = original.get("REMOTE_ADDR") + else: + peer_value = environ.get("REMOTE_ADDR") + peer = _address(peer_value) + networks = _trusted_networks(trusted_proxy_cidrs) + + def trusted(address: IPAddress) -> bool: + return any(address.version == network.version and address in network for network in networks) + + if not trusted(peer): + return str(peer) + header = environ.get("HTTP_X_FORWARDED_FOR") + if not isinstance(header, str) or len(header) > MAX_FORWARDED_HEADER_LENGTH: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + values = header.split(",") + if len(values) > MAX_PROXY_HOPS: + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") + chain = [_address(value) for value in values] + for address in reversed(chain): + if not trusted(address): + return str(address) + raise NetworkAccessClientIPUnavailableError("current client IP is unavailable") diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 0f180d797fc96d..519a0067095d33 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -294,6 +294,13 @@ def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: text=True, ) if result.returncode != 0: + logger.error( + "swagger-markdown failed for %s (exit code %s)\nstdout:\n%s\nstderr:\n%s", + spec_path, + result.returncode, + result.stdout, + result.stderr, + ) raise subprocess.CalledProcessError( result.returncode, result.args, diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 1108149efd68a3..d4105d8db27a61 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -69,6 +69,7 @@ from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository from repositories.installation_state_repository import InstallationStateRepository from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository from repositories.oauth_device_token_repository import SQLAlchemyOAuthDeviceTokenRepository from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository @@ -139,6 +140,11 @@ from services.message_file_preview_service import MessageFilePreviewService from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime from services.message_suggested_questions_service import MessageSuggestedQuestions +from services.network_access_group_gateway import ( + BillingNetworkAccessGroupEntitlementGateway, + NetworkAccessGroupGateway, +) +from services.network_access_group_service import NetworkAccessGroupService from services.notification_gateway import BillingNotificationGateway from services.notification_service import NotificationService from services.oauth_device_adapters import ( @@ -293,6 +299,7 @@ class ApplicationServices: init_validation: InitValidationService installed_apps: InstalledAppServices notifications: NotificationService + network_access_groups: NetworkAccessGroupService step_by_step_tour: StepByStepTourService recommended_app_queries: RecommendedAppQueryService recommended_app_packages: RecommendedAppPackageService @@ -659,6 +666,16 @@ def build_application_services( notifications=NotificationService( notifications=BillingNotificationGateway(), ), + network_access_groups=NetworkAccessGroupService( + control_plane=NetworkAccessGroupGateway( + base_url=dify_config.NETWORK_ACCESS_API_URL, + fallback_base_url=BillingService.base_url, + secret_key=BillingService.secret_key, + ), + apps=SQLAlchemyNetworkAccessGroupAppRepository(session_factory=database_client), + memberships=workspace_repository, + entitlement=BillingNetworkAccessGroupEntitlementGateway(), + ), step_by_step_tour=StepByStepTourService( accounts=accounts, states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client), diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index efb345e70ba4b9..38fb4deb230abb 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -3132,6 +3132,57 @@ Check if app name is available | 200 | Name availability checked | **application/json**: [AppDetail](#appdetail)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [GET] /apps/{app_id}/network-access-group +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App network access group binding retrieved successfully | **application/json**: [AppNetworkAccessGroupResponse](#appnetworkaccessgroupresponse)
| +| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /apps/{app_id}/network-access-group +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AppNetworkAccessGroupUpdatePayload](#appnetworkaccessgroupupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App network access group binding updated successfully | **application/json**: [AppNetworkAccessGroupMutationResponse](#appnetworkaccessgroupmutationresponse)
| +| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /apps/{app_id}/network-access-group/status +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App network access control entry status retrieved successfully | **application/json**: [AppNetworkAccessGroupStatusResponse](#appnetworkaccessgroupstatusresponse)
| +| 404 | Not Found | **application/json**: [AppNotFoundError](#appnotfounderror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [POST] /apps/{app_id}/publish-to-creators-platform #### Parameters @@ -12073,6 +12124,101 @@ Update a plugin endpoint | 200 | Available models retrieved successfully | **application/json**: [AvailableModelListResponse](#availablemodellistresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [GET] /workspaces/current/network-access-groups +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access groups retrieved successfully | **application/json**: [NetworkAccessGroupListResponse](#networkaccessgrouplistresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /workspaces/current/network-access-groups +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NetworkAccessGroupCreatePayload](#networkaccessgroupcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Workspace network access group created successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/network-access-groups/current-ip +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Trusted current client IP retrieved without requiring an existing network access group | **application/json**: [NetworkAccessGroupCurrentIPResponse](#networkaccessgroupcurrentipresponse)
| +| 403 | The workspace role does not allow network access policy reads | **application/json**: [ErrorResponse](#errorresponse)
| +| 503 | A trusted current client IP is unavailable | **application/json**: [ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /workspaces/current/network-access-groups/{group_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| expected_version | query | Current group version used for optimistic concurrency | Yes | integer | +| group_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access group deleted successfully | **application/json**: [NetworkAccessGroupDeleteResponse](#networkaccessgroupdeleteresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/network-access-groups/{group_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access group retrieved successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /workspaces/current/network-access-groups/{group_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [NetworkAccessGroupUpdatePayload](#networkaccessgroupupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace network access group updated successfully | **application/json**: [NetworkAccessGroupMutationResponse](#networkaccessgroupmutationresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /workspaces/current/network-access-groups/{group_id}/check-current-ip +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| group_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Current client IP checked against the workspace network access group | **application/json**: [NetworkAccessGroupCurrentIPCheckResponse](#networkaccessgroupcurrentipcheckresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [GET] /workspaces/current/permission #### Responses @@ -18375,6 +18521,59 @@ The 'More like this' feature is disabled. Please refresh your page. | ---- | ---- | ----------- | -------- | | name | string | Name to check | Yes | +#### AppNetworkAccessGroupBindingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | +| app_id | string | | Yes | +| created_at | dateTime | | Yes | +| enabled | boolean | | Yes | +| group_id | string | | No | +| id | string | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| updated_by_account_id | string | | No | +| version | integer | | Yes | + +#### AppNetworkAccessGroupMutationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| available_access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | +| binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | +| effective_enabled | boolean | | Yes | + +#### AppNetworkAccessGroupResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| available_access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | +| binding | [AppNetworkAccessGroupBindingResponse](#appnetworkaccessgroupbindingresponse) | | Yes | +| effective_enabled | boolean | | Yes | +| entitled | boolean | | Yes | +| tenant_id | string | | Yes | + +#### AppNetworkAccessGroupStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| available_count | integer | | Yes | +| configured | boolean | | Yes | +| covered_count | integer | | Yes | +| enabled | boolean | | Yes | +| entitled | boolean | | Yes | + +#### AppNetworkAccessGroupUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_points | [ string,
**Available values:** "mcp", "service_api", "trigger", "webapp" ] | | Yes | +| enabled | boolean | | Yes | +| expected_version | integer | | Yes | +| group_id | string | | Yes | + #### AppNotFoundError App not found. @@ -23374,6 +23573,87 @@ Need to add ids. | ---- | ---- | ----------- | -------- | | NeedAddIdsError | | Need to add ids. | | +#### NetworkAccessGroupAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| bound_agent_id | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| id | string | | Yes | +| mode | string | | Yes | +| name | string | | Yes | + +#### NetworkAccessGroupCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_cidrs | [ string ] | | Yes | +| description | string | | No | +| name | string | | Yes | + +#### NetworkAccessGroupCurrentIPCheckResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed | boolean | | Yes | +| client_ip | string | | Yes | +| policy_version | integer | | Yes | + +#### NetworkAccessGroupCurrentIPResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_ip | string | | Yes | + +#### NetworkAccessGroupDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| deleted | boolean | | Yes | + +#### NetworkAccessGroupListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| entitled | boolean | | Yes | +| groups | [ [NetworkAccessGroupResponse](#networkaccessgroupresponse) ] | | Yes | +| tenant_id | string | | Yes | + +#### NetworkAccessGroupMutationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| group | [NetworkAccessGroupResponse](#networkaccessgroupresponse) | | Yes | + +#### NetworkAccessGroupResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_cidrs | [ string ] | | Yes | +| app_ids | [ string ] | | Yes | +| apps | [ [NetworkAccessGroupAppResponse](#networkaccessgroupappresponse) ] | | Yes | +| created_at | dateTime | | Yes | +| description | string | | No | +| enforcing_count | integer | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| tenant_id | string | | Yes | +| updated_at | dateTime | | Yes | +| updated_by_account_id | string | | No | +| used_by_count | integer | | Yes | +| version | integer | | Yes | + +#### NetworkAccessGroupUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_cidrs | [ string ] | | Yes | +| description | string | | No | +| expected_version | integer | | Yes | +| name | string | | Yes | + #### NewAppResponse | Name | Type | Description | Required | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 8a7d6fa1a28452..b6ed652be666db 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -491,6 +491,8 @@ Check login status | ---- | ----------- | ------ | | 200 | Login status | **application/json**: [LoginStatusResponse](#loginstatusresponse)
| | 401 | Login status | **application/json**: [ErrorResponse](#errorresponse)
| +| 404 | App not found | **application/json**: [ErrorResponse](#errorresponse)
| +| 503 | Web app access service unavailable | **application/json**: [ErrorResponse](#errorresponse)
| | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| ### [POST] /logout diff --git a/api/repositories/network_access_group_repository.py b/api/repositories/network_access_group_repository.py new file mode 100644 index 00000000000000..f94b6f3bef9b1a --- /dev/null +++ b/api/repositories/network_access_group_repository.py @@ -0,0 +1,121 @@ +"""SQLAlchemy read repository for network access group App projections.""" + +from collections.abc import Sequence +from typing import override + +from sqlalchemy import and_, or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from models import App +from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus +from services.network_access_group_service import ( + NetworkAccessGroupAppQuery, + NetworkAccessGroupAppQueryError, + NetworkAccessGroupAppRecord, +) + + +class SQLAlchemyNetworkAccessGroupAppRepository(NetworkAccessGroupAppQuery): + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + @override + def get_manageable_app(self, *, workspace_id: str, app_id: str) -> NetworkAccessGroupAppRecord | None: + try: + with self._session_factory() as session: + app = session.scalar( + select(App) + .where( + App.id == app_id, + App.tenant_id == workspace_id, + App.status == "normal", + ) + .limit(1) + ) + if app is None: + return None + binding = app.agent_app_binding_with_session(session=session, include_archived=True) + if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY: + return None + bound_agent_id = ( + str(binding.id) + if binding is not None + and binding.scope == AgentScope.ROSTER + and binding.status == AgentStatus.ACTIVE + else None + ) + return self._to_record(app, bound_agent_id=bound_agent_id) + except SQLAlchemyError as exc: + raise NetworkAccessGroupAppQueryError from exc + + @override + def list_apps( + self, + *, + workspace_id: str, + app_ids: Sequence[str], + ) -> tuple[NetworkAccessGroupAppRecord, ...]: + if not app_ids: + return () + try: + with self._session_factory() as session: + apps = tuple( + session.scalars( + select(App).where( + App.tenant_id == workspace_id, + App.id.in_(app_ids), + App.status == "normal", + ) + ).all() + ) + if not apps: + return () + + resolved_app_ids = tuple(app.id for app in apps) + agent_bindings = session.execute( + select(Agent.id, Agent.app_id, Agent.backing_app_id, Agent.scope).where( + Agent.tenant_id == workspace_id, + or_( + and_( + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.app_id.in_(resolved_app_ids), + ), + and_( + Agent.scope == AgentScope.WORKFLOW_ONLY, + Agent.backing_app_id.in_(resolved_app_ids), + ), + ), + ) + ).all() + hidden_app_ids = { + backing_app_id + for _agent_id, _app_id, backing_app_id, scope in agent_bindings + if scope == AgentScope.WORKFLOW_ONLY and backing_app_id is not None + } + bound_agent_ids = { + app_id: str(agent_id) + for agent_id, app_id, _backing_app_id, scope in agent_bindings + if scope == AgentScope.ROSTER and app_id is not None + } + return tuple( + self._to_record(app, bound_agent_id=bound_agent_ids.get(app.id)) + for app in apps + if app.id not in hidden_app_ids + ) + except SQLAlchemyError as exc: + raise NetworkAccessGroupAppQueryError from exc + + @staticmethod + def _to_record(app: App, *, bound_agent_id: str | None = None) -> NetworkAccessGroupAppRecord: + return NetworkAccessGroupAppRecord( + id=str(app.id), + mode=app.mode.value, + name=app.name, + icon=app.icon, + icon_type=app.icon_type.value if app.icon_type is not None else None, + icon_background=app.icon_background, + bound_agent_id=bound_agent_id, + ) diff --git a/api/repositories/web_passport_repository.py b/api/repositories/web_passport_repository.py index f312c92d007374..47ef50fd001a7e 100644 --- a/api/repositories/web_passport_repository.py +++ b/api/repositories/web_passport_repository.py @@ -5,11 +5,38 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session, sessionmaker +from core.app.public_runtime import published_app_filter +from core.db.session_factory import session_factory as global_session_factory from models.enums import AppStatus, EndUserType from models.model import App, EndUser, Site from services.entities.passport_entities import EndUserRecord, WebAppRecord, WebPassportEndUserResolution +def get_web_passport_identity( + app_id: str | None, app_code: str | None, end_user_id: str | None +) -> tuple[App | None, EndUser | None]: + """Resolve a previously issued passport against the current published app and owner chain.""" + with global_session_factory.create_session() as session: + app = session.scalar( + select(App).where(App.id == app_id, App.status == AppStatus.NORMAL, published_app_filter()) + ) + if app is None: + return None, None + site = session.scalar( + select(Site).where(Site.code == app_code, Site.app_id == app.id, Site.status == AppStatus.NORMAL) + ) + if app_code is None or site is None or app.enable_site is False: + return None, None + end_user = session.scalar( + select(EndUser).where( + EndUser.id == end_user_id, + EndUser.app_id == app.id, + EndUser.tenant_id == app.tenant_id, + ) + ) + return app, end_user + + class WebPassportRepository: def __init__( self, @@ -88,6 +115,7 @@ def _active_web_app_stmt(app_code: str): Site.status == AppStatus.NORMAL, App.status == AppStatus.NORMAL, App.enable_site.is_(True), + published_app_filter(), ) ) diff --git a/api/repositories/webapp_access_query_repository.py b/api/repositories/webapp_access_query_repository.py index a3d9180b24ba81..c319ecfecf8f2c 100644 --- a/api/repositories/webapp_access_query_repository.py +++ b/api/repositories/webapp_access_query_repository.py @@ -1,11 +1,14 @@ """Database repository for web-app access queries.""" from typing import override +from uuid import UUID from sqlalchemy import select from sqlalchemy.exc import DBAPIError, TimeoutError from sqlalchemy.orm import Session, sessionmaker +from core.app.public_runtime import published_app_filter +from models.enums import AppStatus from models.model import App, EndUser, Site from services.entities.authentication_entities import WebAppSessionRecord from services.web_authentication_service import WebAppSessionQuery @@ -20,11 +23,48 @@ def __init__(self, *, session_factory: sessionmaker[Session]) -> None: def find_app_id_by_code(self, app_code: str) -> str | None: try: with self._session_factory() as session: - app_id = session.scalar(select(Site.app_id).where(Site.code == app_code).limit(1)) + app_id = session.scalar( + select(App.id) + .join(Site, Site.app_id == App.id) + .where( + Site.code == app_code, + Site.status == AppStatus.NORMAL, + App.status == AppStatus.NORMAL, + App.enable_site.is_(True), + published_app_filter(), + ) + .limit(1) + ) return app_id if app_id is not None else None except (DBAPIError, TimeoutError) as e: raise WebAppAccessUnavailableError from e + @override + def is_app_available(self, app_id: str) -> bool: + try: + canonical_id = str(UUID(app_id)) + except ValueError: + return False + try: + with self._session_factory() as session: + return ( + session.scalar( + select(App.id) + .join(Site, Site.app_id == App.id) + .where( + App.id == canonical_id, + Site.status == AppStatus.NORMAL, + App.status == AppStatus.NORMAL, + App.enable_site.is_(True), + published_app_filter(), + ) + .limit(1) + ) + is not None + ) + except (DBAPIError, TimeoutError) as e: + raise WebAppAccessUnavailableError from e + @override def find_active_session( self, diff --git a/api/services/entities/network_access_group_entities.py b/api/services/entities/network_access_group_entities.py new file mode 100644 index 00000000000000..521c348f7bab84 --- /dev/null +++ b/api/services/entities/network_access_group_entities.py @@ -0,0 +1,102 @@ +"""Immutable application values, independent of SaaS wire names and HTTP DTOs.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, NamedTuple + +NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"] + + +class NetworkAccessGroupAppRecord(NamedTuple): + id: str + mode: str + name: str + icon: str | None + icon_type: str | None + icon_background: str | None + bound_agent_id: str | None = None + + +@dataclass(frozen=True) +class NetworkAccessGroup: + id: str + tenant_id: str + name: str + description: str + allowed_cidrs: tuple[str, ...] + version: int + used_by_count: int + enforcing_count: int + app_ids: tuple[str, ...] + updated_by_account_id: str | None + created_at: datetime + updated_at: datetime + apps: tuple[NetworkAccessGroupAppRecord, ...] = () + + +@dataclass(frozen=True) +class NetworkAccessBinding: + id: str + tenant_id: str + app_id: str + enabled: bool + group_id: str | None + # Preserve legacy/future scope names at the port; the service owns capabilities. + access_points: tuple[str, ...] + version: int + updated_by_account_id: str | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True) +class NetworkAccessGroupList: + tenant_id: str + entitled: bool + groups: tuple[NetworkAccessGroup, ...] + + +@dataclass(frozen=True) +class NetworkAccessAppConfig: + tenant_id: str + app_id: str + entitled: bool + effective_enabled: bool + binding: NetworkAccessBinding | None + available_access_points: tuple[NetworkAccessPoint, ...] = () + + +@dataclass(frozen=True) +class NetworkAccessAppStatusSnapshot: + """Control-plane entry state without policy identity or configuration.""" + + entitled: bool + configured: bool + enabled: bool + # Preserve legacy/future scopes so the service can project current App capabilities. + access_points: tuple[str, ...] + + +@dataclass(frozen=True) +class NetworkAccessAppStatus: + """Public entry status without policy identity or configuration.""" + + entitled: bool + configured: bool + enabled: bool + covered_count: int + available_count: int + + +@dataclass(frozen=True) +class NetworkAccessBindingUpdate: + binding: NetworkAccessBinding + effective_enabled: bool + available_access_points: tuple[NetworkAccessPoint, ...] = () + + +@dataclass(frozen=True) +class NetworkAccessCurrentIPCheck: + client_ip: str + allowed: bool + policy_version: int diff --git a/api/services/network_access_group_gateway.py b/api/services/network_access_group_gateway.py new file mode 100644 index 00000000000000..86fc0b811949ac --- /dev/null +++ b/api/services/network_access_group_gateway.py @@ -0,0 +1,459 @@ +"""HTTP and Billing adapters used by the network access group service.""" + +import json +import re +from collections.abc import Mapping +from datetime import datetime +from typing import Annotated, Any, ClassVar, Literal, override + +import httpx +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + StrictBool, + StrictStr, + ValidationError, + model_validator, +) +from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed + +from core.helper.http_client_pooling import get_pooled_http_client +from enums import CloudPlan +from services.billing_service import BillingService, _BillingHTTPStatusError +from services.entities.network_access_group_entities import ( + NetworkAccessAppConfig, + NetworkAccessAppStatusSnapshot, + NetworkAccessBinding, + NetworkAccessBindingUpdate, + NetworkAccessGroup, + NetworkAccessGroupList, +) +from services.network_access_group_service import ( + NetworkAccessGroupControlPlane, + NetworkAccessGroupEntitlement, + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupInvalidResponseError, + NetworkAccessGroupUpstreamError, +) + + +def _int64(value: object) -> int: + if type(value) is int: + return value + if isinstance(value, str) and re.fullmatch(r"[+-]?[0-9]+", value): + return int(value) + raise ValueError("Expected a ProtoJSON integer or decimal string") + + +_Count = Annotated[int, BeforeValidator(_int64), Field(ge=0, le=2**63 - 1)] +_Version = Annotated[int, BeforeValidator(_int64), Field(ge=1, le=2**63 - 1)] +_Identifier = Annotated[StrictStr, Field(min_length=1)] + + +class _WireModel(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + aliases: ClassVar[Mapping[str, tuple[str, ...]]] = {} + + @model_validator(mode="before") + @classmethod + def canonical_names(cls, value: object) -> object: + if not isinstance(value, dict): + return value + data = dict(value) + for name, aliases in cls.aliases.items(): + supplied = [data[key] for key in (name, *aliases) if key in data] + if not supplied: + continue + if any(type(item) is not type(supplied[0]) or item != supplied[0] for item in supplied[1:]): + raise ValueError(f"Conflicting aliases for {name}") + data[name] = supplied[0] + for alias in aliases: + data.pop(alias, None) + return data + + +class _GroupWire(_WireModel): + aliases = { + "tenant_id": ("tenantId",), + "allowed_cidrs": ("allowedCidrs",), + "used_by_count": ("usedByCount",), + "enforcing_count": ("enforcingCount",), + "app_ids": ("appIds", "used_by_app_ids", "usedByAppIds"), + "updated_by_account_id": ("updatedByAccountId",), + "created_at": ("createdAt",), + "updated_at": ("updatedAt",), + } + id: _Identifier + tenant_id: _Identifier + name: StrictStr + description: StrictStr = "" + allowed_cidrs: Annotated[tuple[StrictStr, ...], Field(min_length=1, max_length=100)] + version: _Version + used_by_count: _Count | None = None + enforcing_count: _Count = 0 + app_ids: tuple[_Identifier, ...] = () + updated_by_account_id: StrictStr | None = None + created_at: datetime + updated_at: datetime + + def record(self) -> NetworkAccessGroup: + return NetworkAccessGroup( + id=self.id, + tenant_id=self.tenant_id, + name=self.name, + description=self.description, + allowed_cidrs=self.allowed_cidrs, + version=self.version, + used_by_count=len(self.app_ids) if self.used_by_count is None else self.used_by_count, + enforcing_count=self.enforcing_count, + app_ids=self.app_ids, + updated_by_account_id=self.updated_by_account_id, + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class _BindingWire(_WireModel): + aliases = { + "tenant_id": ("tenantId",), + "app_id": ("appId",), + "group_id": ("groupId", "policy_id", "policyId"), + "access_points": ("accessPoints",), + "updated_by_account_id": ("updatedByAccountId",), + "created_at": ("createdAt",), + "updated_at": ("updatedAt",), + } + id: _Identifier + tenant_id: _Identifier + app_id: _Identifier + enabled: StrictBool = False + group_id: StrictStr | None = None + access_points: tuple[StrictStr, ...] = () + version: _Version + updated_by_account_id: StrictStr | None = None + created_at: datetime + updated_at: datetime + + def record(self) -> NetworkAccessBinding: + return NetworkAccessBinding( + id=self.id, + tenant_id=self.tenant_id, + app_id=self.app_id, + enabled=self.enabled, + group_id=self.group_id or None, + access_points=self.access_points, + version=self.version, + updated_by_account_id=self.updated_by_account_id, + created_at=self.created_at, + updated_at=self.updated_at, + ) + + +class _GroupResultWire(_WireModel): + group: _GroupWire + + +class _ListWire(_WireModel): + aliases = {"tenant_id": ("tenantId",)} + tenant_id: _Identifier + entitled: StrictBool = False + groups: tuple[_GroupWire, ...] = () + + +class _AppConfigWire(_WireModel): + aliases = {"tenant_id": ("tenantId",), "app_id": ("appId",), "effective_enabled": ("effectiveEnabled",)} + tenant_id: _Identifier + app_id: _Identifier + entitled: StrictBool = False + effective_enabled: StrictBool = False + binding: _BindingWire | None = None + + +class _BindingUpdateWire(_WireModel): + aliases = {"effective_enabled": ("effectiveEnabled",)} + binding: _BindingWire + effective_enabled: StrictBool = False + + +class _AppStatusWire(_WireModel): + aliases = {"access_points": ("accessPoints",)} + entitled: StrictBool = False + configured: StrictBool = False + enabled: StrictBool = False + access_points: tuple[StrictStr, ...] = () + + +class _DeletedWire(_WireModel): + deleted: StrictBool = False + + +def _decode[T: _WireModel](model: type[T], value: dict[str, Any]) -> T: + try: + return model.model_validate(value) + except ValidationError as exc: + raise NetworkAccessGroupInvalidResponseError from exc + + +_http_client: httpx.Client = get_pooled_http_client( + "network-access-group:default", + lambda: httpx.Client( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), + ), +) + + +class NetworkAccessGroupGateway(NetworkAccessGroupControlPlane): + def __init__( + self, + *, + base_url: str, + fallback_base_url: str, + secret_key: str, + http_client: httpx.Client = _http_client, + ) -> None: + self._base_url = base_url.strip() or fallback_base_url + self._secret_key = secret_key + self._http_client = http_client + + @override + def list_groups(self, workspace_id: str, actor_account_id: str) -> NetworkAccessGroupList: + wire = _decode( + _ListWire, + self._send_request( + "GET", + f"/tenants/{workspace_id}/network-access-groups", + params={"actor_account_id": actor_account_id}, + ), + ) + return NetworkAccessGroupList(wire.tenant_id, wire.entitled, tuple(group.record() for group in wire.groups)) + + @override + def create_group( + self, + workspace_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + actor_account_id: str, + ) -> NetworkAccessGroup: + return _decode( + _GroupResultWire, + self._send_request( + "POST", + f"/tenants/{workspace_id}/network-access-groups", + payload_json={ + "name": name, + "description": description, + "allowed_cidrs": allowed_cidrs, + "actor_account_id": actor_account_id, + }, + ), + ).group.record() + + @override + def get_group(self, workspace_id: str, group_id: str, actor_account_id: str) -> NetworkAccessGroup: + return _decode( + _GroupResultWire, + self._send_request( + "GET", + f"/tenants/{workspace_id}/network-access-groups/{group_id}", + params={"actor_account_id": actor_account_id}, + ), + ).group.record() + + @override + def update_group( + self, + workspace_id: str, + group_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + expected_version: int, + actor_account_id: str, + ) -> NetworkAccessGroup: + return _decode( + _GroupResultWire, + self._send_request( + "PUT", + f"/tenants/{workspace_id}/network-access-groups/{group_id}", + payload_json={ + "name": name, + "description": description, + "allowed_cidrs": allowed_cidrs, + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ), + ).group.record() + + @override + def delete_group( + self, + workspace_id: str, + group_id: str, + *, + expected_version: int, + actor_account_id: str, + ) -> bool: + return _decode( + _DeletedWire, + self._send_request( + "DELETE", + f"/tenants/{workspace_id}/network-access-groups/{group_id}", + params={ + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ), + ).deleted + + @override + def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppConfig: + wire = _decode( + _AppConfigWire, + self._send_request( + "GET", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group", + params={"actor_account_id": actor_account_id}, + ), + ) + return NetworkAccessAppConfig( + wire.tenant_id, + wire.app_id, + wire.entitled, + wire.effective_enabled, + wire.binding.record() if wire.binding is not None else None, + ) + + @override + def get_app_status(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppStatusSnapshot: + wire = _decode( + _AppStatusWire, + self._send_request( + "GET", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group/status", + params={"actor_account_id": actor_account_id}, + ), + ) + return NetworkAccessAppStatusSnapshot(wire.entitled, wire.configured, wire.enabled, wire.access_points) + + @override + def update_app_binding( + self, + workspace_id: str, + app_id: str, + *, + enabled: bool, + group_id: str | None, + access_points: list[str], + expected_version: int, + actor_account_id: str, + ) -> NetworkAccessBindingUpdate: + wire = _decode( + _BindingUpdateWire, + self._send_request( + "PUT", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group", + payload_json={ + "enabled": enabled, + "group_id": group_id, + "access_points": access_points, + "expected_version": expected_version, + "actor_account_id": actor_account_id, + }, + ), + ) + return NetworkAccessBindingUpdate(wire.binding.record(), wire.effective_enabled) + + @override + def cleanup_app_binding(self, workspace_id: str, app_id: str) -> bool: + return _decode( + _DeletedWire, + self._send_request( + "DELETE", + f"/tenants/{workspace_id}/apps/{app_id}/network-access-group-binding", + ), + ).deleted + + def _send_request( + self, + method: Literal["GET", "POST", "PUT", "DELETE"], + endpoint: str, + *, + payload_json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = self._send_http_request( + method, + endpoint, + payload_json=payload_json, + params=params, + ) + except httpx.RequestError as exc: + raise NetworkAccessGroupUpstreamError(httpx.codes.SERVICE_UNAVAILABLE) from exc + + if response.status_code not in (httpx.codes.OK, httpx.codes.CREATED): + reason = None + try: + error_payload = response.json() + if isinstance(error_payload, dict) and isinstance(error_payload.get("reason"), str): + reason = error_payload["reason"] + except (json.JSONDecodeError, TypeError, ValueError): + pass + raise NetworkAccessGroupUpstreamError(response.status_code, reason) + + try: + payload = response.json() + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise NetworkAccessGroupInvalidResponseError from exc + + if not isinstance(payload, dict): + raise NetworkAccessGroupInvalidResponseError + return payload + + @retry( + wait=wait_fixed(2), + stop=stop_before_delay(10), + retry=retry_if_exception_type(httpx.RequestError), + reraise=True, + ) + def _send_http_request( + self, + method: Literal["GET", "POST", "PUT", "DELETE"], + endpoint: str, + *, + payload_json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": self._secret_key} + return self._http_client.request( + method, + f"{self._base_url.rstrip('/')}{endpoint}", + json=payload_json, + params=params, + headers=headers, + follow_redirects=True, + ) + + +class BillingNetworkAccessGroupEntitlementGateway(NetworkAccessGroupEntitlement): + @override + def is_paid_plan(self, workspace_id: str) -> bool: + try: + billing_info = BillingService.get_info(workspace_id, exclude_vector_space=True) + except ( + httpx.RequestError, + json.JSONDecodeError, + UnicodeDecodeError, + ValidationError, + _BillingHTTPStatusError, + ) as exc: + raise NetworkAccessGroupEntitlementUnavailableError from exc + return billing_info["subscription"]["plan"] in (CloudPlan.PROFESSIONAL, CloudPlan.TEAM) diff --git a/api/services/network_access_group_service.py b/api/services/network_access_group_service.py new file mode 100644 index 00000000000000..13b4e8b4717420 --- /dev/null +++ b/api/services/network_access_group_service.py @@ -0,0 +1,479 @@ +"""Application service for workspace network access groups.""" + +import ipaddress +import logging +from collections.abc import Callable, Sequence +from dataclasses import replace +from typing import Protocol + +from machinery.context import RequestContext +from services.entities.network_access_group_entities import ( + NetworkAccessAppConfig, + NetworkAccessAppStatus, + NetworkAccessAppStatusSnapshot, + NetworkAccessBinding, + NetworkAccessBindingUpdate, + NetworkAccessCurrentIPCheck, + NetworkAccessGroup, + NetworkAccessGroupAppRecord, + NetworkAccessGroupList, + NetworkAccessPoint, +) + +logger = logging.getLogger(__name__) + +_POLICY_READ_ROLES = frozenset({"owner", "admin", "editor"}) +_POLICY_WRITE_ROLES = frozenset({"owner", "admin"}) +_APP_BINDING_ROLES = _POLICY_READ_ROLES +_APP_STATUS_ROLES = _APP_BINDING_ROLES | frozenset({"normal"}) + +_ACCESS_POINTS_BY_APP_MODE: dict[str, tuple[NetworkAccessPoint, ...]] = { + "workflow": ("webapp", "service_api", "mcp", "trigger"), + "advanced-chat": ("webapp", "service_api", "mcp"), + "chat": ("webapp", "service_api", "mcp"), + "completion": ("webapp", "service_api", "mcp"), + "agent-chat": ("webapp", "service_api", "mcp"), + "agent": ("webapp", "service_api"), +} + + +class NetworkAccessGroupError(Exception): + """Expected network access group use-case failure.""" + + +class NetworkAccessGroupUpstreamError(NetworkAccessGroupError): + """Failure returned by the network-access control plane.""" + + def __init__(self, status_code: int, reason: str | None = None) -> None: + self.status_code = status_code + self.reason = reason + super().__init__(f"network access group upstream returned HTTP {status_code}") + + +class NetworkAccessGroupAccessDeniedError(NetworkAccessGroupError): + """The actor's persisted workspace role does not allow the operation.""" + + +class NetworkAccessGroupEntitlementUnavailableError(NetworkAccessGroupError): + """The workspace plan could not be resolved.""" + + +class NetworkAccessGroupAppNotFoundError(NetworkAccessGroupError): + """The tenant-scoped App does not exist or is not manageable.""" + + +class NetworkAccessGroupUnsupportedAppModeError(NetworkAccessGroupError): + def __init__(self, app_mode: str) -> None: + self.app_mode = app_mode + super().__init__(f"network access control is not supported for app mode {app_mode!r}") + + +class NetworkAccessGroupUnsupportedAccessPointsError(NetworkAccessGroupError): + def __init__(self, access_points: Sequence[str]) -> None: + self.access_points = tuple(access_points) + super().__init__(f"unsupported app access points: {', '.join(self.access_points)}") + + +class NetworkAccessGroupInvalidPolicyError(NetworkAccessGroupError): + """The control plane returned a policy that cannot be evaluated safely.""" + + +class NetworkAccessGroupInvalidResponseError(NetworkAccessGroupError): + """The control plane returned a malformed wire response, not an unavailable service.""" + + +class NetworkAccessGroupAppQueryError(Exception): + """The local App read model could not be queried.""" + + +class NetworkAccessGroupAppQuery(Protocol): + def get_manageable_app(self, *, workspace_id: str, app_id: str) -> NetworkAccessGroupAppRecord | None: ... + + def list_apps( + self, + *, + workspace_id: str, + app_ids: Sequence[str], + ) -> Sequence[NetworkAccessGroupAppRecord]: ... + + +class WorkspaceMembershipRoleQuery(Protocol): + def get_account_role(self, *, tenant_id: str, account_id: str) -> str | None: ... + + +class NetworkAccessGroupEntitlement(Protocol): + def is_paid_plan(self, workspace_id: str) -> bool: ... + + +class NetworkAccessGroupControlPlane(Protocol): + def list_groups(self, workspace_id: str, actor_account_id: str) -> NetworkAccessGroupList: ... + + def create_group( + self, + workspace_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + actor_account_id: str, + ) -> NetworkAccessGroup: ... + + def get_group(self, workspace_id: str, group_id: str, actor_account_id: str) -> NetworkAccessGroup: ... + + def update_group( + self, + workspace_id: str, + group_id: str, + *, + name: str, + description: str, + allowed_cidrs: list[str], + expected_version: int, + actor_account_id: str, + ) -> NetworkAccessGroup: ... + + def delete_group( + self, + workspace_id: str, + group_id: str, + *, + expected_version: int, + actor_account_id: str, + ) -> bool: ... + + def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> NetworkAccessAppConfig: ... + + def get_app_status( + self, workspace_id: str, app_id: str, actor_account_id: str + ) -> NetworkAccessAppStatusSnapshot: ... + + def update_app_binding( + self, + workspace_id: str, + app_id: str, + *, + enabled: bool, + group_id: str | None, + access_points: list[str], + expected_version: int, + actor_account_id: str, + ) -> NetworkAccessBindingUpdate: ... + + def cleanup_app_binding(self, workspace_id: str, app_id: str) -> bool: ... + + +class NetworkAccessGroupService: + def __init__( + self, + *, + control_plane: NetworkAccessGroupControlPlane, + apps: NetworkAccessGroupAppQuery, + memberships: WorkspaceMembershipRoleQuery, + entitlement: NetworkAccessGroupEntitlement, + ) -> None: + self._control_plane = control_plane + self._apps = apps + self._memberships = memberships + self._entitlement = entitlement + + def list_groups(self, context: RequestContext) -> NetworkAccessGroupList: + self._ensure_workspace_role(context, _POLICY_READ_ROLES) + result = self._control_plane.list_groups(context.active_workspace_id, context.account_id) + entitled = self._effective_entitlement(context.active_workspace_id, result.entitled) + groups = result.groups if entitled else tuple(replace(group, enforcing_count=0) for group in result.groups) + return replace(result, entitled=entitled, groups=self._enrich_groups(groups, context.active_workspace_id)) + + def create_group( + self, + context: RequestContext, + *, + name: str, + description: str, + allowed_cidrs: list[str], + ) -> NetworkAccessGroup: + self._ensure_workspace_role(context, _POLICY_WRITE_ROLES) + group = self._control_plane.create_group( + context.active_workspace_id, + name=name, + description=description, + allowed_cidrs=allowed_cidrs, + actor_account_id=context.account_id, + ) + return self._enrich_groups((group,), context.active_workspace_id)[0] + + def get_group(self, context: RequestContext, *, group_id: str) -> NetworkAccessGroup: + self._ensure_workspace_role(context, _POLICY_READ_ROLES) + group = self._control_plane.get_group( + context.active_workspace_id, + group_id, + context.account_id, + ) + return self._enrich_groups((group,), context.active_workspace_id)[0] + + def update_group( + self, + context: RequestContext, + *, + group_id: str, + name: str, + description: str, + allowed_cidrs: list[str], + expected_version: int, + ) -> NetworkAccessGroup: + self._ensure_workspace_role(context, _POLICY_WRITE_ROLES) + group = self._control_plane.update_group( + context.active_workspace_id, + group_id, + name=name, + description=description, + allowed_cidrs=allowed_cidrs, + expected_version=expected_version, + actor_account_id=context.account_id, + ) + return self._enrich_groups((group,), context.active_workspace_id)[0] + + def delete_group( + self, + context: RequestContext, + *, + group_id: str, + expected_version: int, + ) -> bool: + self._ensure_workspace_role(context, _POLICY_WRITE_ROLES) + return self._control_plane.delete_group( + context.active_workspace_id, + group_id, + expected_version=expected_version, + actor_account_id=context.account_id, + ) + + def get_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppConfig: + self._ensure_workspace_role(context, _APP_BINDING_ROLES) + return self._read_app_binding(context, app_id=app_id) + + def get_app_status(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppStatus: + """Expose only the Access Point entry state to Normal workspace members.""" + self._ensure_workspace_role(context, _APP_STATUS_ROLES) + app = self._get_manageable_app(context.active_workspace_id, app_id) + available_access_points = self._available_access_points(app.mode) + status = self._control_plane.get_app_status(context.active_workspace_id, app.id, context.account_id) + return NetworkAccessAppStatus( + entitled=self._effective_entitlement(context.active_workspace_id, status.entitled), + configured=status.configured, + enabled=status.enabled, + covered_count=len(set(status.access_points).intersection(available_access_points)), + available_count=len(available_access_points), + ) + + def _read_app_binding(self, context: RequestContext, *, app_id: str) -> NetworkAccessAppConfig: + app = self._get_manageable_app(context.active_workspace_id, app_id) + available_access_points = self._available_access_points(app.mode) + result = self._control_plane.get_app_binding( + context.active_workspace_id, + app.id, + context.account_id, + ) + binding = self._filter_binding(result.binding, available_access_points) + entitled = self._effective_entitlement(context.active_workspace_id, result.entitled) + return replace( + result, + binding=binding, + entitled=entitled, + available_access_points=available_access_points, + effective_enabled=self._effective_enabled(result.effective_enabled, binding, entitled), + ) + + def update_app_binding( + self, + context: RequestContext, + *, + app_id: str, + enabled: bool, + group_id: str | None, + access_points: list[NetworkAccessPoint], + expected_version: int, + ) -> NetworkAccessBindingUpdate: + self._ensure_workspace_role(context, _APP_BINDING_ROLES) + app = self._get_manageable_app(context.active_workspace_id, app_id) + available_access_points = self._available_access_points(app.mode) + self._validate_app_access_points(access_points, available_access_points) + result = self._control_plane.update_app_binding( + context.active_workspace_id, + app.id, + enabled=enabled, + group_id=group_id, + access_points=list(access_points), + expected_version=expected_version, + actor_account_id=context.account_id, + ) + binding = replace( + result.binding, + access_points=tuple(point for point in result.binding.access_points if point in available_access_points), + ) + return replace( + result, + binding=binding, + available_access_points=available_access_points, + effective_enabled=self._effective_enabled(result.effective_enabled, binding), + ) + + def get_current_ip( + self, + context: RequestContext, + *, + client_ip_supplier: Callable[[], str], + ) -> str: + """Return trusted request metadata without requiring a saved policy. + + Like policy reads and the existing IP preflight, this read-only helper + authorizes the persisted workspace role before resolving the request IP. + It does not create a policy, enable enforcement, or require a paid plan; + policy mutations keep their separate paid-plan admission. + """ + + self._ensure_workspace_role(context, _POLICY_READ_ROLES) + return client_ip_supplier() + + def check_current_ip( + self, + context: RequestContext, + *, + group_id: str, + client_ip_supplier: Callable[[], str], + ) -> NetworkAccessCurrentIPCheck: + """Evaluate the request's trusted client IP against one tenant policy. + + The supplier is deliberately lazy: persisted workspace authorization and + tenant-scoped policy resolution both complete before request IP metadata + is read or returned. + """ + + self._ensure_workspace_role(context, _POLICY_READ_ROLES) + group = self._control_plane.get_group( + context.active_workspace_id, + group_id, + context.account_id, + ) + client_ip = client_ip_supplier() + allowed = self._policy_allows_client_ip(group.allowed_cidrs, client_ip) + return NetworkAccessCurrentIPCheck(client_ip, allowed, group.version) + + def cleanup_app_binding(self, *, workspace_id: str, app_id: str) -> bool: + return self._control_plane.cleanup_app_binding(workspace_id, app_id) + + def _ensure_workspace_role(self, context: RequestContext, allowed_roles: frozenset[str]) -> None: + role = self._memberships.get_account_role( + tenant_id=context.active_workspace_id, + account_id=context.account_id, + ) + if role not in allowed_roles: + raise NetworkAccessGroupAccessDeniedError + + def _effective_entitlement(self, workspace_id: str, upstream_entitled: bool) -> bool: + return upstream_entitled and self._entitlement.is_paid_plan(workspace_id) + + def _get_manageable_app(self, workspace_id: str, app_id: str) -> NetworkAccessGroupAppRecord: + app = self._apps.get_manageable_app(workspace_id=workspace_id, app_id=app_id) + if app is None: + raise NetworkAccessGroupAppNotFoundError + return app + + @staticmethod + def _available_access_points(app_mode: str) -> tuple[NetworkAccessPoint, ...]: + available = _ACCESS_POINTS_BY_APP_MODE.get(app_mode) + if available is None: + raise NetworkAccessGroupUnsupportedAppModeError(app_mode) + return available + + @staticmethod + def _validate_app_access_points( + requested: Sequence[NetworkAccessPoint], + available: Sequence[NetworkAccessPoint], + ) -> None: + unsupported = sorted(set(requested).difference(available)) + if unsupported: + raise NetworkAccessGroupUnsupportedAccessPointsError(unsupported) + + @staticmethod + def _filter_binding( + binding: NetworkAccessBinding | None, + available: Sequence[NetworkAccessPoint], + ) -> NetworkAccessBinding | None: + """Project current capabilities without mutating the saved binding snapshot.""" + if binding is None: + return None + return replace(binding, access_points=tuple(point for point in binding.access_points if point in available)) + + @staticmethod + def _effective_enabled(upstream: bool, binding: NetworkAccessBinding | None, entitled: bool = True) -> bool: + return bool( + upstream + and entitled + and binding is not None + and binding.enabled + and binding.group_id + and binding.access_points + ) + + @staticmethod + def _policy_allows_client_ip(raw_cidrs: Sequence[object], client_ip: str) -> bool: + if not raw_cidrs or "%" in client_ip: + raise NetworkAccessGroupInvalidPolicyError + try: + address = NetworkAccessGroupService._normalize_ip_address(ipaddress.ip_address(client_ip)) + networks = [NetworkAccessGroupService._normalize_ip_network(raw_cidr) for raw_cidr in raw_cidrs] + except (TypeError, ValueError) as exc: + raise NetworkAccessGroupInvalidPolicyError from exc + return any(address.version == network.version and address in network for network in networks) + + @staticmethod + def _normalize_ip_address( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, + ) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None: + return address.ipv4_mapped + return address + + @staticmethod + def _normalize_ip_network(raw_cidr: object) -> ipaddress.IPv4Network | ipaddress.IPv6Network: + """Normalize a management display entry for matching without rewriting it.""" + if not isinstance(raw_cidr, str) or "%" in raw_cidr: + raise TypeError("policy CIDR must be a string") + address_value, separator, prefix_value = raw_cidr.partition("/") + if separator and (not prefix_value.isascii() or not prefix_value.isdecimal()): + raise ValueError("policy CIDR prefix must be a decimal length") + original_address = ipaddress.ip_address(address_value) + # Management replies preserve bare IPs and CIDR host bits for display. + # ip_network assigns a host prefix to bare IPs and masks only this local value. + network = ipaddress.ip_network(raw_cidr, strict=False) + if not isinstance(original_address, ipaddress.IPv6Address) or original_address.ipv4_mapped is None: + return network + prefix_length = network.prefixlen + if prefix_length < 96: + raise ValueError("IPv4-mapped IPv6 policy prefixes must be at least /96") + return ipaddress.IPv4Network((original_address.ipv4_mapped, prefix_length - 96), strict=False) + + def _enrich_groups( + self, + groups: Sequence[NetworkAccessGroup], + workspace_id: str, + ) -> tuple[NetworkAccessGroup, ...]: + """Attach tenant-scoped App display metadata to immutable policies.""" + app_ids = {app_id for group in groups for app_id in group.app_ids} + if not app_ids: + return tuple(replace(group, apps=()) for group in groups) + + try: + app_records = self._apps.list_apps(workspace_id=workspace_id, app_ids=tuple(app_ids)) + except NetworkAccessGroupAppQueryError: + logger.exception( + "Failed to enrich network access group App references", + extra={"workspace_id": workspace_id}, + ) + return tuple(replace(group, apps=()) for group in groups) + + apps_by_id = {app.id: app for app in app_records} + return tuple( + replace(group, apps=tuple(apps_by_id[app_id] for app_id in group.app_ids if app_id in apps_by_id)) + for group in groups + ) diff --git a/api/services/webapp_access_query_service.py b/api/services/webapp_access_query_service.py index 61c25a2dd3c374..19ea1656581dcf 100644 --- a/api/services/webapp_access_query_service.py +++ b/api/services/webapp_access_query_service.py @@ -11,6 +11,8 @@ class WebAppAccessQuery(Protocol): def find_app_id_by_code(self, app_code: str) -> str | None: ... + def is_app_available(self, app_id: str) -> bool: ... + class WebAppAccessPolicyGateway(Protocol): def get_access_mode(self, app_id: str) -> WebAppAccessMode: ... @@ -60,16 +62,34 @@ def get_app_id_by_code(self, app_code: str) -> str: raise WebAppAccessAppNotFoundError(f"App with code {app_code} not found") return app_id - def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode: - if not self._webapp_auth_enabled: - return WebAppAccessMode.PUBLIC - + def resolve_app_id(self, *, app_id: str | None = None, app_code: str | None = None) -> str: + """Resolve a published public App identity without tokens or user writes.""" if app_code: - app_id = self.get_app_id_by_code(app_code) - + app_id = self._access.find_app_id_by_code(app_code) + if app_id is None: + raise WebAppAccessAppNotFoundError + elif app_id and not self._access.is_app_available(app_id): + raise WebAppAccessAppNotFoundError if not app_id: raise WebAppAccessReferenceRequiredError("appId or appCode must be provided") + return app_id + def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode: + # Preserve the auth-disabled, reference-free bootstrap shortcut. Supplied + # references must still resolve to a live App regardless of auth settings. + if not self._webapp_auth_enabled and not app_id and not app_code: + return WebAppAccessMode.PUBLIC + app_id = self.resolve_app_id(app_id=app_id, app_code=app_code) + return self.get_configured_access_mode(app_id=app_id) + + def get_configured_access_mode(self, *, app_id: str) -> WebAppAccessMode: + """Read policy for an App already authorized by a management context. + + Management must be able to configure unpublished or disabled sites. + Public callers must use get_access_mode to retain publication admission. + """ + if not self._webapp_auth_enabled: + return WebAppAccessMode.PUBLIC return self._policy.get_access_mode(app_id) def find_app_id_by_code(self, app_code: str) -> str | None: diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 982dc70d26df71..5c4aa6d6137012 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -58,12 +58,63 @@ logger = logging.getLogger(__name__) +NETWORK_ACCESS_BINDING_CLEANUP_DELAY_SECONDS = 10 +NETWORK_ACCESS_BINDING_CLEANUP_RETRY_BASE_SECONDS = 15 +NETWORK_ACCESS_BINDING_CLEANUP_RETRY_MAX_SECONDS = 10 * 60 + + +def _network_access_binding_cleanup_retry_delay(retries: int) -> int: + exponent = min(max(retries, 0), 6) + return min( + NETWORK_ACCESS_BINDING_CLEANUP_RETRY_BASE_SECONDS * (2**exponent), + NETWORK_ACCESS_BINDING_CLEANUP_RETRY_MAX_SECONDS, + ) + + +@shared_task(queue="app_deletion", bind=True, max_retries=24) +def cleanup_app_network_access_group_binding_task(self, tenant_id: str, app_id: str) -> None: + """Reliably remove a SaaS binding after its owning App was committed deleted.""" + + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + return + try: + # Import lazily because AppService imports this task module while the + # application-service composition root imports AppService dependencies. + from extensions.ext_application_services import application_services + + application_services().network_access_groups.cleanup_app_binding( + workspace_id=tenant_id, + app_id=app_id, + ) + except Exception as e: + countdown = _network_access_binding_cleanup_retry_delay(self.request.retries) + logger.exception( + "Failed to clean up App network access group binding", + extra={"tenant_id": tenant_id, "app_id": app_id, "retry_countdown": countdown}, + ) + raise self.retry(exc=e, countdown=countdown) from e + + +def _schedule_app_network_access_group_binding_cleanup(tenant_id: str, app_id: str) -> None: + """Schedule only from App lifecycle cleanup, never account/member deletion.""" + + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + return + cleanup_app_network_access_group_binding_task.apply_async( + kwargs={"tenant_id": tenant_id, "app_id": app_id}, + countdown=NETWORK_ACCESS_BINDING_CLEANUP_DELAY_SECONDS, + ) + @shared_task(queue="app_deletion", bind=True, max_retries=3) def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green")) start_at = time.perf_counter() try: + # AppService publishes this task after the App transaction commits. + # Schedule the independent high-retry SaaS cleanup before legacy local + # cleanup so a later local failure cannot strand the binding. + _schedule_app_network_access_group_binding_cleanup(tenant_id, app_id) # Preserve ownership records until storage cleanup succeeds, so retries # can still locate files. External I/O runs outside DB transactions. cleanup_app_storage(tenant_id, app_id) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index e5f6f83d768a4b..89a1ec9aaefc52 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -7,8 +7,8 @@ import pytest from flask import Flask from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden +from controllers.web.error import WebAppNotFoundError from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse from models import Tenant, TenantStatus from models.account import TenantCustomConfigDict @@ -97,17 +97,17 @@ def test_happy_path(self, mock_features: MagicMock, app: Flask, db_session_with_ assert result["enable_site"] is True assert result["mode"] == AppMode.CHAT - def test_missing_site_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: + def test_missing_site_raises_not_found(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers) app_model = _create_app(db_session_with_containers, tenant.id) end_user = _end_user(tenant.id, app_model.id) with app.test_request_context("/site"): - with pytest.raises(Forbidden): + with pytest.raises(WebAppNotFoundError): AppSiteApi().get(app_model, end_user) - def test_archived_tenant_raises_forbidden(self, app: Flask, db_session_with_containers: Session) -> None: + def test_archived_tenant_raises_not_found(self, app: Flask, db_session_with_containers: Session) -> None: app.config["RESTX_MASK_HEADER"] = "X-Fields" tenant = _create_tenant(db_session_with_containers, status=TenantStatus.ARCHIVE) app_model = _create_app(db_session_with_containers, tenant.id) @@ -115,7 +115,7 @@ def test_archived_tenant_raises_forbidden(self, app: Flask, db_session_with_cont end_user = _end_user(tenant.id, app_model.id) with app.test_request_context("/site"): - with pytest.raises(Forbidden): + with pytest.raises(WebAppNotFoundError): AppSiteApi().get(app_model, end_user) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py index 394ea794185fec..5ae39b9f3ccca5 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py @@ -10,9 +10,9 @@ import pytest from flask import Flask from sqlalchemy.orm import Session -from werkzeug.exceptions import BadRequest, NotFound, Unauthorized +from werkzeug.exceptions import NotFound, Unauthorized -from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError +from controllers.web.error import WebAppAuthAccessDeniedError, WebAppAuthRequiredError, WebAppNotFoundError from controllers.web.wraps import ( _validate_user_accessibility, _validate_webapp_token, @@ -229,7 +229,7 @@ def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def _create_app_site_enduser(self, db_session: Session, *, enable_site: bool = True): - from models.model import App, AppMode, CustomizeTokenStrategy, EndUser, Site + from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site tenant_id = str(uuid4()) app_model = App( @@ -243,6 +243,13 @@ def _create_app_site_enduser(self, db_session: Session, *, enable_site: bool = T db_session.commit() db_session.expire_all() + # The happy-path fixture represents a published public App. + config = AppModelConfig(app_id=app_model.id) + db_session.add(config) + db_session.flush() + app_model.app_model_config_id = config.id + db_session.commit() + site = Site( app_id=app_model.id, title="test-site", @@ -331,13 +338,13 @@ def test_missing_app_raises_not_found( mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": "code1"}): - with pytest.raises(NotFound): + with pytest.raises(WebAppNotFoundError): decode_jwt_token() @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") @patch("controllers.web.wraps.PassportService") @patch("controllers.web.wraps.extract_webapp_passport") - def test_disabled_site_raises_bad_request( + def test_disabled_site_raises_app_not_found( self, mock_extract: MagicMock, mock_passport_cls: MagicMock, @@ -356,7 +363,7 @@ def test_disabled_site_raises_bad_request( mock_features.return_value = False with app.test_request_context("/", headers={"X-App-Code": site.code}): - with pytest.raises(BadRequest, match="Site is disabled"): + with pytest.raises(WebAppNotFoundError): decode_jwt_token() @patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled") diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py index be024904b22ecf..87795748909906 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -469,6 +469,34 @@ def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path: Path) assert module._patch_union_schema_markdown("#### BrokenUnion\n", spec_path) == "#### BrokenUnion\n" +def test_convert_spec_to_markdown_logs_converter_failure_without_overwriting_docs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-openapi.json" + output_path = tmp_path / "console-openapi.md" + spec_path.write_text("{}", encoding="utf-8") + output_path.write_text("Existing documentation\n", encoding="utf-8") + + def run_converter(args, **_kwargs): + return module.subprocess.CompletedProcess( + args=args, returncode=1, stdout="Loading converter", stderr="Unable to resolve npm runtime" + ) + + monkeypatch.setattr(module.subprocess, "run", run_converter) + + with pytest.raises(module.subprocess.CalledProcessError) as exc_info: + module._convert_spec_to_markdown(spec_path, output_path) + + assert exc_info.value.returncode == 1 + assert exc_info.value.stdout == "Loading converter" + assert exc_info.value.stderr == "Unable to resolve npm runtime" + assert str(spec_path) in caplog.text + assert "Loading converter" in caplog.text + assert "Unable to resolve npm runtime" in caplog.text + assert output_path.read_text(encoding="utf-8") == "Existing documentation\n" + + @pytest.mark.parametrize("required", ["Yes", "No"]) @pytest.mark.parametrize("content_count", [1, 2, 3]) def test_convert_spec_to_markdown_patches_generated_union_tables( diff --git a/api/tests/unit_tests/controllers/common/test_access_response.py b/api/tests/unit_tests/controllers/common/test_access_response.py new file mode 100644 index 00000000000000..0081f5f5c001f8 --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_access_response.py @@ -0,0 +1,531 @@ +"""Final Flask response contracts; all database/token/execution boundaries are isolated.""" + +import io +import json +import logging +from collections.abc import Buffer, Generator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Literal, Never, cast, override +from unittest.mock import MagicMock + +import pytest +from flask import Blueprint, Flask, Response, jsonify, request +from flask.testing import FlaskClient +from flask_restx import Namespace, Resource +from pytest_mock import MockerFixture +from sqlalchemy import Table +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import NotFound, Unauthorized + +import controllers.service_api.wraps as token_wraps +import controllers.trigger.trigger as plugin_trigger +import services.api_token_service as token_service +from controllers.common.access_response import ( + MCP_ERROR_BODY_LIMIT, + MISSING_BEARER_MESSAGE, + mcp_server_not_found_response, + register_auth_error_response, +) +from controllers.mcp import mcp +from controllers.trigger import webhook +from extensions import ext_request_logging +from libs.external_api import ExternalApi +from models.engine import db +from models.model import ApiToken, App, AppMCPServer +from services.app.mcp_server_service import AppMCPServerStatus +from tests.unit_tests.config_override import apply_config_overrides + +type TokenClient = tuple[FlaskClient, MagicMock, MagicMock, MagicMock] + + +@dataclass +class SessionCalls: + session: Session + scalar: MagicMock + add: MagicMock + execute: MagicMock + + +def _session_calls(mocker: MockerFixture) -> SessionCalls: + session = Session() + return SessionCalls( + session=session, + scalar=mocker.patch.object(session, "scalar"), + add=mocker.patch.object(session, "add"), + execute=mocker.patch.object(session, "execute"), + ) + + +def _mcp_server(app_id: str) -> AppMCPServer: + server = AppMCPServer( + app_id=app_id, + tenant_id="tenant", + status=AppMCPServerStatus.ACTIVE, + name="Fixture", + description="", + server_code="fixture", + parameters="{}", + ) + server.id = "server" + return server + + +type MCPClient = tuple[FlaskClient, SessionCalls, MagicMock, MagicMock] + + +@pytest.fixture +def http_app() -> Flask: + app = Flask(__name__) + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI="sqlite:///:memory:") + db.init_app(app) + return app + + +@pytest.fixture +def token_client(http_app: Flask, mocker: MockerFixture) -> TokenClient: + bp = Blueprint("service_test", __name__, url_prefix="/v1") + register_auth_error_response(bp, www_authenticate='Bearer realm="api"') + api = ExternalApi(bp) + + class TokenResource(Resource): + def get(self) -> dict[str, bool]: + token_wraps.validate_and_get_api_token("app") + return {"ok": True} + + api.add_resource(TokenResource, "/info") + http_app.register_blueprint(bp) + other = Blueprint("other_test", __name__, url_prefix="/other") + other_api = ExternalApi(other) + + class OtherResource(Resource): + def get(self) -> Never: + raise Unauthorized("Other API authentication") + + other_api.add_resource(OtherResource, "/info") + http_app.register_blueprint(other) + cache_get = mocker.patch.object(token_wraps.ApiTokenCache, "get", return_value=None) + fetch = mocker.patch.object( + token_wraps, "fetch_token_with_single_flight", side_effect=Unauthorized("Access token is invalid") + ) + usage = mocker.patch.object(token_wraps, "record_token_usage") + return http_app.test_client(), cache_get, fetch, usage + + +@pytest.mark.parametrize( + "header", + [ + None, + "", + "Bearer", + "Bearer ", + "Bearer\t", + " ", + "Basic abc", + "Bearer a b", + "Bearer\xa0valid-token", + "Bearer\x1cvalid-token", + "Bearer\vvalid-token", + "Bearer\u2003valid-token", + ], +) +def test_bad_bearer_final_response_never_looks_up_or_records_token( + token_client: TokenClient, header: str | None +) -> None: + client, cache_get, fetch, usage = token_client + response = client.get("/v1/info", headers={} if header is None else {"Authorization": header}) + assert response.status_code == 401 + expected = {"code": "unauthorized", "message": MISSING_BEARER_MESSAGE, "status": 401} + assert response.data == json.dumps(expected, separators=(",", ":"), sort_keys=True).encode() + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"' + cache_get.assert_not_called() + fetch.assert_not_called() + usage.assert_not_called() + + +def test_invalid_token_final_response_and_no_usage(token_client: TokenClient) -> None: + client, _cache_get, fetch, usage = token_client + response = client.get("/v1/info", headers={"Authorization": "Bearer missing-token"}) + assert response.status_code == 401 + assert response.data == b'{"code":"unauthorized","message":"Access token is invalid","status":401}' + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"' + fetch.assert_called_once_with("missing-token", "app") + usage.assert_not_called() + + +def test_invalid_token_database_path_does_not_record_usage(http_app: Flask, mocker: MockerFixture) -> None: + usage = mocker.patch.object(token_service, "record_token_usage") + mocker.patch.object(token_service.ApiTokenCache, "set") + with http_app.app_context(): + cast(Table, ApiToken.__table__).create(db.engine) + with pytest.raises(Unauthorized, match="Access token is invalid"): + token_service.query_token_from_db("missing-token", "app") + usage.assert_not_called() + + +def test_success_token_and_other_surface_are_not_rewritten(token_client: TokenClient) -> None: + client, cache_get, _fetch, usage = token_client + cache_get.return_value = ApiToken(id="existing") + response = client.get("/v1/info", headers={"Authorization": "bEaReR\tvalid-token"}) + assert response.status_code == 200 + assert response.get_json() == {"ok": True} + assert "Cache-Control" not in response.headers + usage.assert_called_once_with("valid-token", "app") + other = client.get("/other/info") + assert other.status_code == 401 + assert other.get_json()["message"] == "Other API authentication" + assert "Cache-Control" not in other.headers + + +@pytest.fixture +def mcp_client(http_app: Flask, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> MCPClient: + bp = Blueprint("mcp_test", __name__, url_prefix="/mcp") + api = ExternalApi(bp) + namespace = Namespace("mcp_test", path="/") + namespace.models.update(mcp.mcp_ns.models) + namespace.add_resource(mcp.MCPAppApi, "/server//mcp") + api.add_namespace(namespace) + http_app.register_blueprint(bp) + monkeypatch.setattr(mcp, "mcp_ns", namespace) + calls = _session_calls(mocker) + maker = sessionmaker() + + @contextmanager + def begin() -> Generator[Session]: + yield calls.session + + mocker.patch.object(maker, "begin", side_effect=begin) + monkeypatch.setattr(mcp.session_factory, "get_session_maker", lambda: maker) + execute = mocker.patch.object(mcp, "handle_mcp_request") + end_user = mocker.patch.object(mcp.MCPAppApi, "_retrieve_end_user") + return http_app.test_client(), calls, execute, end_user + + +@pytest.mark.parametrize("cached", [False, True]) +@pytest.mark.parametrize("identity", ["missing-server", "missing-app", "inactive"]) +@pytest.mark.parametrize( + ("body", "expected_id"), + [ + (b'{"id":17}', b"17"), + (b'{"id":-0}', b"-0"), + (b'{"id":1.2500}', b"1.2500"), + (b'{"id":1e+90}', b"1e+90"), + (b'{"id":900719925474099312345678901234567890}', b"900719925474099312345678901234567890"), + (b'{"id":"request-1"}', b'"request-1"'), + ('{"id":"<>&雪\u2028\u2029"}'.encode(), '"<>&雪\\u2028\\u2029"'.encode()), + (b'{"id":true}', b"null"), + (b'{"id":[]}', b"null"), + (b'{"id":{}}', b"null"), + (b'{"id":null}', b"null"), + (b"{}", b"null"), + (b'[{"id":1}]', b"null"), + (b'{"id":NaN}', b"null"), + (b'{"id":Infinity}', b"null"), + (b'{"id":', b"null"), + (b'{"id":1}{}', b"null"), + (b'\xef\xbb\xbf{"id":1}', b"null"), + ('{"id":1}'.encode("utf-16"), b"null"), + (b'{"id":"\xff"}', b"null"), + (b'{"id":"\\ud800"}', b"null"), + (b'{"id":"\\udfff"}', b"null"), + (b'{"id":"\\ud83d\\ude00"}', '"😀"'.encode()), + (b'{"id":"\\\\ud800"}', b'"\\\\ud800"'), + (b'{"id":1,"params":' + b"[" * 63 + b"0" + b"]" * 63 + b"}", b"1"), + (b'{"id":1,"params":' + b"[" * 64 + b"0" + b"]" * 64 + b"}", b"null"), + (b'{"id":1,"params":"' + b"[" * 100 + b'\\"}"}', b"1"), + (b'{"id":1}' + b" " * (MCP_ERROR_BODY_LIMIT - len(b'{"id":1}')), b"1"), + (b'{"id":1}' + b" " * MCP_ERROR_BODY_LIMIT, b"null"), + ], +) +def test_mcp_missing_identity_final_response_is_bounded_and_opaque( + mcp_client: MCPClient, + identity: Literal["missing-server", "missing-app", "inactive"], + cached: bool, + body: bytes, + expected_id: bytes, +) -> None: + client, session, execute, end_user = mcp_client + server = _mcp_server("private-app") + if cached: + + @client.application.before_request + def cache_body() -> None: + request.get_data() + + if identity == "missing-server": + session.scalar.side_effect = [None] + elif identity == "missing-app": + session.scalar.side_effect = [server, None] + else: + server.status = "inactive" + session.scalar.side_effect = [server, App(id="private-app")] + response = client.post("/mcp/server/missing-fixture/mcp", data=body, content_type="application/json") + assert response.status_code == 404 + assert ( + response.data + == b'{"error":{"code":-32600,"message":"Server Not Found"},"id":' + expected_id + b',"jsonrpc":"2.0"}' + ) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + execute.assert_not_called() + end_user.assert_not_called() + session.add.assert_not_called() + session.execute.assert_not_called() + + +@pytest.mark.parametrize("cached", [False, True]) +@pytest.mark.parametrize("oversized", [False, True]) +def test_mcp_unknown_length_stream_read_is_capped(http_app: Flask, cached: bool, oversized: bool) -> None: + class RecordingStream(io.BytesIO): + requested: list[int | None] = [] + + @override + def read(self, size: int | None = -1) -> bytes: + self.requested.append(size) + return super().read(size) + + stream = RecordingStream(b'{"id":1}' + (b" " * MCP_ERROR_BODY_LIMIT if oversized else b"")) + with http_app.test_request_context( + "/", method="POST", environ_overrides={"wsgi.input": stream, "wsgi.input_terminated": True} + ): + if cached: + request.get_data() + stream.requested.clear() + response = mcp_server_not_found_response() + assert stream.requested == ([] if cached else [MCP_ERROR_BODY_LIMIT + 1]) + assert response.get_json()["id"] == (None if oversized else 1) + + +def test_mcp_debug_request_logging_preserves_raw_error_id( + mcp_client: MCPClient, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + client, session, execute, end_user = mcp_client + session.scalar.return_value = None + caplog.set_level(logging.DEBUG, logger=ext_request_logging.__name__) + # Exercise the real existing request-started logger, including request.data caching. + apply_config_overrides(monkeypatch, ENABLE_REQUEST_LOGGING=True) + ext_request_logging.init_app(client.application) + response = client.post("/mcp/server/missing-fixture/mcp", data=b'{"id":1.2500}', content_type="application/json") + assert response.status_code == 404 + assert b'"id":1.2500,' in response.data + assert "Received Request" in caplog.text + execute.assert_not_called() + end_user.assert_not_called() + + +def test_existing_mcp_keeps_normal_large_payload_and_response(mcp_client: MCPClient, mocker: MockerFixture) -> None: + client, session, _execute, _end_user = mcp_client + server = _mcp_server("existing-app") + app = App(id="existing-app", tenant_id="tenant") + session.scalar.side_effect = [server, app, server, app] + mocker.patch.object(mcp.MCPAppApi, "_get_user_input_form", return_value=[]) + expected_body = b'{"id":2,"jsonrpc":"2.0","result":{}}' + process = mocker.patch.object( + mcp.MCPAppApi, + "_process_mcp_message", + return_value=Response(expected_body, status=200, content_type="application/json"), + ) + payload = { + "jsonrpc": "2.0", + "method": "initialize", + "id": 2, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {"experimental": {"fixture": {"padding": "x" * (MCP_ERROR_BODY_LIMIT + 1)}}}, + "clientInfo": {"name": "fixture", "version": "1"}, + }, + } + response = client.post("/mcp/server/existing-fixture/mcp", json=payload) + assert response.status_code == 200 + assert response.data == expected_body + assert "Cache-Control" not in response.headers + process.assert_called_once() + + +def test_existing_mcp_still_validates_malformed_payload(mcp_client: MCPClient) -> None: + client, session, execute, end_user = mcp_client + session.scalar.side_effect = [ + _mcp_server("existing-app"), + App(id="existing-app", tenant_id="tenant"), + ] + response = client.post("/mcp/server/existing-fixture/mcp", data=b"{", content_type="application/json") + assert response.status_code == 400 + execute.assert_not_called() + end_user.assert_not_called() + + +@pytest.mark.parametrize( + "change", + [ + "initial-missing", + "missing-server", + "missing-app", + "inactive", + "server-replaced", + "app-rebound", + "tenant-changed", + "unchanged", + ], +) +def test_mcp_reads_body_outside_sessions_and_rechecks_identity_before_execution( + mcp_client: MCPClient, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, change: str +) -> None: + client, _session, execute, end_user = mcp_client + active_sessions: list[Session] = [] + opened: list[Session] = [] + read_sessions: list[bool] = [] + initial_server = _mcp_server("app") + initial_app = App(id="app", tenant_id="tenant") + fresh_server = _mcp_server("app") + fresh_app = App(id="app", tenant_id="tenant") + if change == "inactive": + fresh_server.status = "inactive" + elif change == "server-replaced": + fresh_server.id = "other-server" + elif change == "app-rebound": + fresh_server.app_id = fresh_app.id = "other-app" + elif change == "tenant-changed": + fresh_server.tenant_id = fresh_app.tenant_id = "other-tenant" + initial_calls = _session_calls(mocker) + initial = initial_calls.session + initial_calls.scalar.side_effect = [None] if change == "initial-missing" else [initial_server, initial_app] + execution_calls = _session_calls(mocker) + execution = execution_calls.session + execution_calls.scalar.side_effect = ( + [None] if change == "missing-server" else [fresh_server, None if change == "missing-app" else fresh_app] + ) + remaining = iter((initial, execution)) + + @contextmanager + def begin() -> Generator[Session]: + session = next(remaining) + opened.append(session) + active_sessions.append(session) + try: + yield session + finally: + active_sessions.pop() + + maker = sessionmaker() + mocker.patch.object(maker, "begin", side_effect=begin) + monkeypatch.setattr(mcp.session_factory, "get_session_maker", lambda: maker) + form = mocker.patch.object(mcp.MCPAppApi, "_get_user_input_form", return_value=[]) + process = mocker.patch.object(mcp.MCPAppApi, "_process_mcp_message", return_value=Response("ok", status=200)) + original_error = mcp.mcp_server_not_found_response + + def error_response() -> Response: + assert not active_sessions, "404 response must be constructed after releasing sessions" + return original_error() + + monkeypatch.setattr(mcp, "mcp_server_not_found_response", error_response) + + class RecordingStream(io.BytesIO): + @override + def read(self, size: int | None = -1) -> bytes: + read_sessions.append(bool(active_sessions)) + return super().read(size) + + @override + def readinto(self, buffer: Buffer) -> int: + read_sessions.append(bool(active_sessions)) + return super().readinto(buffer) + + payload = b'{"jsonrpc":"2.0","method":"ping","id":900719925474099312345678901234567890}' + response = client.post( + "/mcp/server/fixture/mcp", input_stream=RecordingStream(payload), content_type="application/json" + ) + assert read_sessions + assert not any(read_sessions) + assert opened == ([initial] if change == "initial-missing" else [initial, execution]) + if change == "unchanged": + assert response.status_code == 200 + form.assert_called_once_with(fresh_app, session=execution) + assert process.call_args.args[2] is fresh_app + assert process.call_args.args[3] is fresh_server + assert process.call_args.args[5] is execution + else: + assert response.status_code == 404 + assert b'"id":900719925474099312345678901234567890,' in response.data + form.assert_not_called() + process.assert_not_called() + execute.assert_not_called() + end_user.assert_not_called() + initial_calls.add.assert_not_called() + execution_calls.add.assert_not_called() + + +@pytest.mark.parametrize("route", ["webhook", "webhook-debug"]) +def test_webhook_missing_identity_uses_native_html_without_disclosing_id( + http_app: Flask, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, route: Literal["webhook", "webhook-debug"] +) -> None: + monkeypatch.setattr(webhook, "request", request) + monkeypatch.setattr(webhook, "jsonify", jsonify) + mocker.patch.object( + webhook.WebhookService, + "get_webhook_trigger_and_workflow", + side_effect=ValueError("Webhook not found: private-fixture"), + ) + execute = mocker.patch.object(webhook.WebhookService, "trigger_workflow_execution") + handler = webhook.handle_webhook if route == "webhook" else webhook.handle_webhook_debug + http_app.add_url_rule(f"/triggers/{route}/", view_func=handler, methods=["POST"]) + response = http_app.test_client().post(f"/triggers/{route}/private-fixture", json={}) + assert response.status_code == 404 + assert response.data == NotFound().get_body().encode() + assert response.headers["Content-Type"] == "text/html; charset=utf-8" + assert response.headers["Cache-Control"] == "no-store" + assert b"private-fixture" not in response.data + execute.assert_not_called() + + +@pytest.mark.parametrize("endpoint", ["123e4567-e89b-42d3-a456-426614174000", "invalid-id"]) +def test_plugin_callback_retains_its_json_error_contract( + http_app: Flask, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, endpoint: str +) -> None: + monkeypatch.setattr(plugin_trigger, "request", request) + monkeypatch.setattr(plugin_trigger, "jsonify", jsonify) + mocker.patch.object(plugin_trigger.TriggerService, "process_endpoint", return_value=None) + mocker.patch.object( + plugin_trigger.TriggerSubscriptionBuilderService, + "process_builder_validation_endpoint", + return_value=None, + ) + http_app.add_url_rule( + "/triggers/plugin/", view_func=plugin_trigger.trigger_endpoint, methods=["POST"] + ) + response = http_app.test_client().post(f"/triggers/plugin/{endpoint}", json={}) + assert response.status_code == 404 + assert response.data == b'{"error":"Endpoint not found"}' + assert response.mimetype == "application/json" + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("status", [401, 403, 429]) +def test_openapi_local_hook_preserves_native_details_and_oauth_challenge(http_app: Flask, status: int) -> None: + payload = { + "code": "unauthorized", + "message": "invalid_token", + "status": status, + "hint": "native hint", + "details": [{"type": "native", "loc": ["header"], "msg": "detail"}], + } + bp = Blueprint("openapi_test", __name__, url_prefix="/openapi/v1") + register_auth_error_response(bp) + + @bp.route("/fixture") + def fixture() -> tuple[Response, int, dict[str, str]]: + return jsonify(payload), status, {"WWW-Authenticate": 'Bearer realm="native-openapi", error="invalid_token"'} + + http_app.register_blueprint(bp) + response = http_app.test_client().get("/openapi/v1/fixture") + assert response.status_code == status + assert response.get_json() == payload + assert response.headers["WWW-Authenticate"] == 'Bearer realm="native-openapi", error="invalid_token"' + if status == 401: + assert response.data == json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + assert response.headers["Cache-Control"] == "no-store" + else: + assert "Cache-Control" not in response.headers diff --git a/api/tests/unit_tests/controllers/common/test_app_access_error.py b/api/tests/unit_tests/controllers/common/test_app_access_error.py new file mode 100644 index 00000000000000..f0eb6666b72c6d --- /dev/null +++ b/api/tests/unit_tests/controllers/common/test_app_access_error.py @@ -0,0 +1,500 @@ +import json +from collections.abc import Callable, Iterator +from typing import Literal +from uuid import UUID + +import pytest +from flask import Blueprint, Flask, Response +from flask.typing import ResponseReturnValue +from flask_restx import Resource +from pytest_mock import MockerFixture +from werkzeug.exceptions import NotFound +from werkzeug.middleware.proxy_fix import ProxyFix + +from controllers.common.app_access_error import register_app_access_error_metadata +from libs.exception import BaseHTTPException +from libs.external_api import ExternalApi +from tests.unit_tests.config_override import apply_config_overrides + +APP_ID = "9d6e3fb3-94ce-48f9-a958-58ca80b1c02e" +TRUSTED = "172.18.0.0/16,10.0.0.0/8,2400:cb00::/32" +WEB_IDENTITIES = ("/site", "/parameters", "/meta", "/passport", "/login/status", "/webapp/access-mode") +CONSOLE_IDENTITIES = ( + "/apps/", + "/agent/", + "/installed-apps/", + "/installed-apps//parameters", + "/installed-apps//meta", + "/trial-apps/", + "/trial-apps//parameters", +) +OTHER_ROUTES = ( + "/chat-messages", + "/form/human_input/", + "/form/human_input//upload-token", + "/human-input-forms/files", + "/conversations/", + "/apps//workflows/", + "/agent//versions/", + "/installed-apps//conversations", + "/trial-apps//workflows", +) + + +class AppNotFoundError(BaseHTTPException): + error_code = "app_not_found" + code = 404 + description = "App not found." + + +class AgentNotFoundError(NotFound): + description = "Agent not found." + + +def _missing_app() -> ResponseReturnValue: + raise NotFound("App not found.") + + +def _create_app(responder: Callable[[], ResponseReturnValue] = _missing_app, *, use_proxy_fix: bool = False) -> Flask: + app = Flask(__name__) + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + if use_proxy_fix: + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) + for surface, prefix in (("web", "/api"), ("console", "/console/api"), ("service", "/v1")): + bp = Blueprint(surface, __name__, url_prefix=prefix) + if surface == "web": + register_app_access_error_metadata(bp, surface="web") + elif surface == "console": + register_app_access_error_metadata(bp, surface="console") + api = ExternalApi(bp) + + class AppResource(Resource): + def get(self, **_kwargs: str | UUID) -> ResponseReturnValue: + return responder() + + def post(self, **_kwargs: str | UUID) -> ResponseReturnValue: + return responder() + + # Include the opposite surface's identity paths to prove the allowlists + # are independent, not broad suffix/path matches. + api.add_resource(AppResource, *WEB_IDENTITIES, *CONSOLE_IDENTITIES, *OTHER_ROUTES) + app.register_blueprint(bp) + return app + + +def _path(rule: str) -> str: + for name in ("app_id", "agent_id", "installed_app_id", "workflow_id", "conversation_id", "version_id"): + rule = rule.replace(f"", APP_ID) + return rule.replace("", "missing-form-token") + + +@pytest.fixture(autouse=True) +def _trusted_proxies(monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, NETWORK_ACCESS_TRUSTED_PROXY_CIDRS=TRUSTED) + + +@pytest.mark.parametrize("rule", WEB_IDENTITIES) +def test_web_app_identity_404s_receive_metadata(rule: str) -> None: + response = _create_app().test_client().get("/api" + rule, environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + assert response.status_code == 404 + assert response.get_json() == { + "code": "app_not_found", + "message": "App not found.", + "status": 404, + "client_ip": "203.0.113.42", + } + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("rule", CONSOLE_IDENTITIES) +def test_console_app_identity_404s_receive_metadata(rule: str) -> None: + response = ( + _create_app().test_client().get("/console/api" + _path(rule), environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + ) + assert response.status_code == 404 + assert response.get_json()["client_ip"] == "203.0.113.42" + assert response.get_json()["code"] == "not_found" + + +@pytest.mark.parametrize("prefix", ["/api", "/console/api"]) +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_typed_app_not_found_keeps_exception_payload_after_restx_formatting(prefix: str, method: str) -> None: + def missing() -> ResponseReturnValue: + error = AppNotFoundError() + assert error.data is not None + error.data["detail"] = {"reason": "unavailable"} + raise error + + response = ( + _create_app(missing) + .test_client() + .open(prefix + "/chat-messages", method=method, environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + ) + assert response.status_code == 404 + expected: dict[str, object] = { + "code": "app_not_found", + "message": "App not found.", + "status": 404, + "client_ip": "203.0.113.42", + } + if prefix == "/console/api": + expected["detail"] = {"reason": "unavailable"} + assert response.get_json() == expected + + +def test_agent_error_code_is_not_reclassified() -> None: + def missing() -> ResponseReturnValue: + raise AgentNotFoundError() + + response = ( + _create_app(missing) + .test_client() + .get(f"/console/api/agent/{APP_ID}", environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + ) + assert response.status_code == 404 + assert response.get_json()["code"] == "agent_not_found_error" + assert response.get_json()["message"] == "Agent not found." + assert response.get_json()["client_ip"] == "203.0.113.42" + + +@pytest.mark.parametrize( + ("peer", "forwarded", "expected"), + [ + ("203.0.113.42", "192.0.2.1, invalid", "203.0.113.42"), + ("2001:db8::42", "192.0.2.1", "2001:db8::42"), + ("::ffff:203.0.113.42", "192.0.2.1", "203.0.113.42"), + ("172.18.0.2", "192.0.2.1, 203.0.113.42, 10.2.3.4", "203.0.113.42"), + ("172.18.0.2", "2001:0db8::42, 2400:cb00::1, 10.2.3.4", "2001:db8::42"), + ("::ffff:172.18.0.2", "::ffff:203.0.113.42, ::ffff:10.2.3.4", "203.0.113.42"), + ], +) +@pytest.mark.parametrize("use_proxy_fix", [False, True]) +def test_trust_boundary_ignores_spoofed_arguments_and_headers( + peer: str, forwarded: str, expected: str, use_proxy_fix: bool +) -> None: + response = ( + _create_app(use_proxy_fix=use_proxy_fix) + .test_client() + .get( + "/api/site?client_ip=192.0.2.123&app_code=does-not-exist", + json={"client_ip": "192.0.2.124"}, + headers={"X-Forwarded-For": forwarded, "X-Real-IP": "192.0.2.125", "CF-Connecting-IP": "192.0.2.126"}, + environ_overrides={"REMOTE_ADDR": peer}, + ) + ) + assert response.status_code == 404 + assert response.get_json()["client_ip"] == expected + + +@pytest.mark.parametrize("config", ["", " ", "not-a-cidr", "172.18.0.1/16", "172.18.0.0/16,"]) +def test_missing_or_invalid_trust_configuration_preserves_original_404( + monkeypatch: pytest.MonkeyPatch, config: str +) -> None: + apply_config_overrides(monkeypatch, NETWORK_ACCESS_TRUSTED_PROXY_CIDRS=config) + response = ( + _create_app() + .test_client() + .get("/api/site", environ_overrides={"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": "203.0.113.42"}) + ) + assert response.status_code == 404 + assert response.get_json() == {"code": "app_not_found", "message": "App not found.", "status": 404} + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("forwarded", [None, "", "unknown", "192.0.2.1, invalid", "10.2.3.4", "192.0.2.1," * 33]) +def test_unavailable_client_ip_never_exposes_internal_peer_or_changes_status(forwarded: str | None) -> None: + environ = {"REMOTE_ADDR": "172.18.0.2"} + if forwarded is not None: + environ["HTTP_X_FORWARDED_FOR"] = forwarded + response = _create_app().test_client().get("/api/site", environ_overrides=environ) + assert response.status_code == 404 + assert "client_ip" not in response.get_json() + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("config", ["", "not-a-cidr", TRUSTED]) +def test_unavailable_client_ip_removes_existing_unverified_metadata( + monkeypatch: pytest.MonkeyPatch, config: str +) -> None: + apply_config_overrides(monkeypatch, NETWORK_ACCESS_TRUSTED_PROXY_CIDRS=config) + payload = { + "code": "app_not_found", + "message": "missing", + "status": 404, + "client_ip": "192.0.2.123", + "extra": "kept", + } + response = ( + _create_app(lambda: (payload, 404)) + .test_client() + .get("/api/site", environ_overrides={"REMOTE_ADDR": "172.18.0.2"}) + ) + assert response.status_code == 404 + assert response.get_json() == {"code": "app_not_found", "message": "App not found.", "status": 404} + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("prefix", ["/api", "/console/api"]) +@pytest.mark.parametrize("rule", [rule for rule in OTHER_ROUTES if "human_input" in rule or "human-input" in rule]) +def test_typed_app_not_found_on_human_input_forms_is_unchanged(prefix: str, rule: str) -> None: + def missing() -> ResponseReturnValue: + raise AppNotFoundError() + + response = _create_app(missing).test_client().get(prefix + _path(rule)) + assert response.status_code == 404 + assert response.get_json() == {"code": "app_not_found", "message": "App not found.", "status": 404} + assert "Cache-Control" not in response.headers + + +def test_response_metadata_is_request_local_and_disables_shared_caching() -> None: + payload = {"code": "app_not_found", "message": "missing", "status": 404, "extra": ["kept"]} + client = _create_app( + lambda: (payload, 404, {"Cache-Control": "public, max-age=600", "X-Custom": "kept"}) + ).test_client() + for peer in ("203.0.113.42", "2001:db8::43", "172.18.0.2"): + response = client.get("/api/site", environ_overrides={"REMOTE_ADDR": peer}) + assert response.get_json().get("client_ip") == (None if peer == "172.18.0.2" else peer) + assert "extra" not in response.get_json() + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["X-Custom"] == "kept" + assert "client_ip" not in payload + + +@pytest.mark.parametrize("prefix", ["/api", "/console/api"]) +@pytest.mark.parametrize("rule", OTHER_ROUTES) +def test_unrelated_resource_and_human_input_form_404s_are_unchanged(prefix: str, rule: str) -> None: + response = _create_app().test_client().get(prefix + _path(rule)) + assert response.status_code == 404 + if prefix == "/api" and rule.startswith("/form/human_input/"): + assert response.get_json() == {"code": "not_found", "message": "Form not found", "status": 404} + assert response.headers["Cache-Control"] == "no-store" + else: + assert response.get_json() == {"code": "not_found", "message": "App not found.", "status": 404} + assert "Cache-Control" not in response.headers + + +@pytest.mark.parametrize("code", ["app_unavailable", "agent_not_published"]) +@pytest.mark.parametrize("route", WEB_IDENTITIES) +def test_web_unavailable_and_unpublished_identity_uses_same_404(code: str, route: str) -> None: + payload = {"code": code, "message": "private configuration details", "status": 400, "app_name": "private"} + response = ( + _create_app(lambda: (payload, 400)) + .test_client() + .get("/api" + route, environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + ) + assert response.status_code == 404 + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + assert response.data == ( + b'{"client_ip":"203.0.113.42","code":"app_not_found","message":"App not found.","status":404}' + ) + + +@pytest.mark.parametrize("code", ["not_found", "app_not_found", "agent_not_found_error"]) +def test_web_missing_identity_has_one_minimal_canonical_wire_contract(code: str) -> None: + payload = {"code": code, "message": "private cause", "status": 404, "policy_id": "private", "app_name": "private"} + response = ( + _create_app(lambda: (payload, 404)) + .test_client() + .get("/api/passport", environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + ) + assert response.data == ( + b'{"client_ip":"203.0.113.42","code":"app_not_found","message":"App not found.","status":404}' + ) + assert response.headers["Content-Type"] == "application/json" + + +def test_hitl_upload_invalid_token_keeps_native_contract_without_ip() -> None: + payload = {"code": "invalid_upload_token", "message": "private cause", "status": 403, "client_ip": "192.0.2.1"} + response = _create_app(lambda: (payload, 403)).test_client().post("/api/human-input-forms/files") + assert response.status_code == 403 + assert response.data == ( + b'{"code":"invalid_upload_token","message":"Upload token is invalid or expired.","status":403}' + ) + assert response.headers["Cache-Control"] == "no-store" + assert response.headers["Content-Type"] == "application/json" + + +@pytest.mark.parametrize("prefix", ["/api", "/console/api"]) +@pytest.mark.parametrize("status", [200, 400, 401, 403, 503]) +def test_success_and_non_404_errors_are_unchanged(prefix: str, status: int) -> None: + # Even an inconsistent error code cannot override the HTTP status contract. + payload = {"code": "app_not_found", "message": "unchanged", "status": status} + response = _create_app(lambda: (payload, status)).test_client().get(prefix + "/site") + assert response.status_code == status + assert response.get_json() == payload + assert "Cache-Control" not in response.headers + + +# These sentinel responses intentionally omit contract fields and forbid JSON +# reads; the global schema checker would itself violate the behavior under test. +@pytest.mark.allow_error_contract_violations +@pytest.mark.parametrize("path", ["/api/site", f"/console/api/apps/{APP_ID}"]) +@pytest.mark.parametrize("status", [200, 201, 204, 301, 302, 303, 304, 307, 308, 401, 500, 503]) +def test_non_candidate_statuses_do_not_parse_json(mocker: MockerFixture, path: str, status: int) -> None: + body = b'{"code":"app_not_found","message":"unchanged","data":[1,2,3]}' + original = Response(body, status=status, mimetype="application/json") + parse = mocker.patch.object( + original, "get_json", side_effect=AssertionError("Non-candidate response must not be parsed") + ) + app = _create_app(lambda: original) + + with app.test_request_context(path): + response = app.full_dispatch_request() + + assert response is original + assert response.status_code == status + assert response.data == body + assert "Cache-Control" not in response.headers + parse.assert_not_called() + + +@pytest.mark.parametrize( + ("path", "method", "status"), + [ + (f"/console/api/apps/{APP_ID}", "GET", 400), + (f"/console/api/apps/{APP_ID}", "GET", 403), + ("/console/api/human-input-forms/files", "POST", 403), + ("/console/api/form/human_input/token", "GET", 404), + ("/api/chat-messages", "GET", 400), + ("/api/site", "POST", 400), + ("/api/form/human_input/token", "GET", 400), + ("/api/site", "GET", 403), + ("/api/chat-messages", "POST", 403), + ("/api/form/human_input/token", "GET", 403), + ("/api/human-input-forms/files", "POST", 404), + ], +) +@pytest.mark.allow_error_contract_violations +def test_route_and_surface_ineligible_errors_do_not_parse_json( + mocker: MockerFixture, path: str, method: str, status: int +) -> None: + body = b'{"code":"app_not_found","message":"unchanged"}' + original = Response(body, status=status, mimetype="application/json") + parse = mocker.patch.object(original, "get_json", side_effect=AssertionError("Ineligible error must not be parsed")) + app = _create_app(lambda: original) + + with app.test_request_context(path, method=method): + response = app.full_dispatch_request() + + assert response is original + assert response.status_code == status + assert response.data == body + assert "Cache-Control" not in response.headers + parse.assert_not_called() + + +@pytest.mark.parametrize("status", [200, 400, 403, 404]) +@pytest.mark.parametrize("direct_passthrough", [False, True]) +@pytest.mark.allow_error_contract_violations +def test_candidate_and_success_streams_never_parse_or_consume_response( + mocker: MockerFixture, status: int, direct_passthrough: bool +) -> None: + consumed: list[bool] = [] + + def stream() -> Iterator[str]: + consumed.append(True) + yield '{"code":"app_not_found"}' + + original = Response(stream(), status, mimetype="application/json", direct_passthrough=direct_passthrough) + parse = mocker.patch.object(original, "get_json", side_effect=AssertionError("Stream must not be parsed")) + app = _create_app(lambda: original) + with app.test_request_context("/api/site"): + response = app.full_dispatch_request() + + assert response is original + assert response.status_code == status + assert "Cache-Control" not in response.headers + assert not consumed + parse.assert_not_called() + + +def test_trial_forbidden_is_not_reclassified_as_missing_app() -> None: + payload = {"code": "forbidden", "message": "App unavailable", "status": 403} + response = _create_app(lambda: (payload, 403)).test_client().get(f"/console/api/trial-apps/{APP_ID}") + assert response.status_code == 403 + assert response.get_json() == payload + assert "Cache-Control" not in response.headers + + +@pytest.mark.parametrize("prefix", ["/api", "/console/api"]) +def test_non_get_untyped_identity_404_is_unchanged(prefix: str) -> None: + response = _create_app().test_client().post(prefix + "/site") + assert response.status_code == 404 + assert "client_ip" not in response.get_json() + assert "Cache-Control" not in response.headers + + +@pytest.mark.parametrize( + "path", ["/api/unknown", "/console/api/unknown", "/api/site/unknown", "/console/api/apps/not-a-uuid", "/elsewhere"] +) +def test_unknown_routes_preserve_default_html_not_found(path: str) -> None: + response = _create_app().test_client().get(path) + assert response.status_code == 404 + assert response.mimetype == "text/html" + assert "Cache-Control" not in response.headers + + +@pytest.mark.parametrize("path", [f"/api/apps/{APP_ID}", "/console/api/site", "/v1/site", f"/v1/apps/{APP_ID}"]) +def test_identity_allowlist_does_not_leak_between_surfaces(path: str) -> None: + response = _create_app().test_client().get(path) + assert response.status_code == 404 + assert "client_ip" not in response.get_json() + assert "Cache-Control" not in response.headers + + +def test_typed_service_api_error_is_unchanged() -> None: + def missing() -> ResponseReturnValue: + raise AppNotFoundError() + + response = _create_app(missing).test_client().get("/v1/chat-messages") + assert response.status_code == 404 + assert response.get_json()["code"] == "app_not_found" + assert "client_ip" not in response.get_json() + assert "Cache-Control" not in response.headers + + +@pytest.mark.parametrize("kind", ["stream", "passthrough", "html", "list", "null", "invalid-json"]) +@pytest.mark.allow_error_contract_violations +def test_non_json_object_and_streamed_responses_are_not_consumed(kind: str) -> None: + consumed: list[bool] = [] + + def respond() -> Response: + if kind in {"stream", "passthrough"}: + + def stream() -> Iterator[str]: + consumed.append(True) + yield '{"code":"app_not_found"}' + + return Response(stream(), 404, mimetype="application/json", direct_passthrough=kind == "passthrough") + if kind == "html": + return Response("

Missing

", 404, mimetype="text/html") + data = {"list": json.dumps([{"code": "app_not_found"}]), "null": "null", "invalid-json": "{"}[kind] + return Response(data, 404, mimetype="application/json") + + app = _create_app(respond) + with app.test_request_context("/api/site", environ_overrides={"REMOTE_ADDR": "203.0.113.42"}): + response = app.full_dispatch_request() + assert response.status_code == 404 + assert "Cache-Control" not in response.headers + assert not consumed + + +@pytest.mark.parametrize("surface", ["web", "console"]) +def test_production_blueprints_register_metadata_hook_and_all_identity_rules( + surface: Literal["web", "console"], +) -> None: + if surface == "web": + from controllers.web import bp + + routes = WEB_IDENTITIES + else: + from controllers.console import bp + + routes = CONSOLE_IDENTITIES + assert any(hook.__name__ == "add_app_access_error_metadata" for hook in bp.after_request_funcs[None]) + app = Flask(__name__) + app.register_blueprint(bp) + actual_rules = {rule.rule for rule in app.url_map.iter_rules()} + assert bp.url_prefix is not None + assert {bp.url_prefix + route for route in routes} <= actual_rules diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index b04f21e2518710..c9299baab6ec43 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -3,6 +3,7 @@ from typing import override from unittest.mock import MagicMock, patch +import httpx import pytest from flask import Flask from flask_login import LoginManager, UserMixin @@ -850,6 +851,30 @@ def paid_view(): assert exc_info.value.code == 403 assert "requires a paid plan" in str(exc_info.value.description) + def test_should_return_service_unavailable_when_billing_lookup_fails(self) -> None: + app = create_app_with_login() + + @cloud_edition_billing_paid_plan_required + def paid_view() -> str: + return "paid_success" + + with app.test_request_context(): + with ( + patch( + "controllers.console.wraps.current_account_with_tenant", + return_value=(MockUser("test_user"), "tenant123"), + ), + patch( + "controllers.console.wraps.BillingService.get_info", + side_effect=httpx.ConnectError("billing unavailable"), + ), + pytest.raises(HTTPException) as exc_info, + ): + paid_view() + + assert exc_info.value.code == 503 + assert "temporarily unavailable" in str(exc_info.value.description) + class TestBillingResourceLimits: """Test billing resource limit decorators""" diff --git a/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py new file mode 100644 index 00000000000000..c410d3942a93dc --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_network_access_group.py @@ -0,0 +1,1095 @@ +from collections.abc import Callable, Generator, Mapping +from contextlib import contextmanager +from dataclasses import replace +from datetime import UTC, datetime +from inspect import unwrap +from unittest.mock import patch +from uuid import UUID + +import httpx +import pytest +from flask import Flask, Response +from pydantic import ValidationError +from pytest_mock import MockerFixture +from sqlalchemy.orm import sessionmaker +from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable +from werkzeug.test import TestResponse + +from controllers.console.app.error import AppNotFoundError +from controllers.console.workspace.network_access_group import ( + AppNetworkAccessGroupApi, + AppNetworkAccessGroupStatusApi, + AppNetworkAccessGroupUpdatePayload, + CurrentWorkspaceNetworkAccessGroupApi, + CurrentWorkspaceNetworkAccessGroupCurrentIPApi, + CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi, + CurrentWorkspaceNetworkAccessGroupsApi, + NetworkAccessGroupCreatePayload, + NetworkAccessGroupCurrentIPResponse, + NetworkAccessGroupDeleteQuery, + NetworkAccessGroupUpdatePayload, + _translate_service_error, + _translate_upstream_error, +) +from controllers.console.wraps import validate_request +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError +from enums import DeploymentEdition +from extensions.ext_application_services import ApplicationServices +from extensions.ext_login import DifyLoginManager +from libs.login import AccountWithTenant +from machinery.context import RequestContext +from models.account import Account, AccountStatus +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository +from repositories.workspace.workspace_repository import WorkspaceRepository +from services.entities.network_access_group_entities import ( + NetworkAccessAppConfig, + NetworkAccessAppStatus, + NetworkAccessBinding, + NetworkAccessBindingUpdate, + NetworkAccessCurrentIPCheck, + NetworkAccessGroup, + NetworkAccessGroupList, +) +from services.network_access_group_gateway import BillingNetworkAccessGroupEntitlementGateway, NetworkAccessGroupGateway +from services.network_access_group_service import ( + NetworkAccessGroupAccessDeniedError, + NetworkAccessGroupAppNotFoundError, + NetworkAccessGroupAppRecord, + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupError, + NetworkAccessGroupInvalidPolicyError, + NetworkAccessGroupInvalidResponseError, + NetworkAccessGroupService, + NetworkAccessGroupUnsupportedAccessPointsError, + NetworkAccessGroupUnsupportedAppModeError, + NetworkAccessGroupUpstreamError, +) + +TENANT_ID = "11111111-1111-4111-8111-111111111111" +ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" +APP_ID = "33333333-3333-4333-8333-333333333333" +GROUP_ID = "44444444-4444-4444-8444-444444444444" +BINDING_ID = "55555555-5555-4555-8555-555555555555" + + +def _request_context() -> RequestContext: + return RequestContext( + request_id="request-id", + trace_id=None, + account_id=ACCOUNT_ID, + active_workspace_id=TENANT_ID, + ) + + +@contextmanager +def _application_services(services: ApplicationServices) -> Generator[None]: + with patch( + "controllers.console.workspace.network_access_group.application_services", + return_value=services, + ): + yield + + +def _group_payload(*, app_ids: list[str] | None = None) -> NetworkAccessGroup: + app_ids = app_ids or [] + return NetworkAccessGroup( + id=GROUP_ID, + tenant_id=TENANT_ID, + name="Office network", + description="Reusable office egress addresses", + allowed_cidrs=("203.0.113.7/32",), + used_by_count=len(app_ids), + enforcing_count=0, + app_ids=tuple(app_ids), + version=2, + updated_by_account_id=ACCOUNT_ID, + created_at=datetime(2026, 8, 21, tzinfo=UTC), + updated_at=datetime(2026, 8, 21, tzinfo=UTC), + ) + + +def _binding_payload( + *, + enabled: bool = True, + group_id: str | None = GROUP_ID, + access_points: list[str] | None = None, +) -> NetworkAccessBinding: + return NetworkAccessBinding( + id=BINDING_ID, + tenant_id=TENANT_ID, + app_id=APP_ID, + enabled=enabled, + group_id=group_id, + access_points=("webapp", "service_api") if access_points is None else tuple(access_points), + version=3, + updated_by_account_id=ACCOUNT_ID, + created_at=datetime(2026, 8, 21, tzinfo=UTC), + updated_at=datetime(2026, 8, 21, tzinfo=UTC), + ) + + +def test_list_forwards_request_context_and_serializes_response( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mock_list_groups = mocker.patch.object( + service, "list_groups", return_value=NetworkAccessGroupList(TENANT_ID, True, (_group_payload(),)) + ) + api = CurrentWorkspaceNetworkAccessGroupsApi() + + with _application_services(account_application_services): + result = unwrap(api.get)(api, request_context=_request_context()) + + mock_list_groups.assert_called_once_with(_request_context()) + assert result["tenant_id"] == TENANT_ID + assert result["groups"][0]["version"] == 2 + assert result["groups"][0]["updated_at"] == "2026-08-21T00:00:00Z" + + +def test_create_forwards_payload_and_returns_201( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mock_create_group = mocker.patch.object(service, "create_group", return_value=_group_payload()) + request_payload = NetworkAccessGroupCreatePayload( + name="Office network", + description="Reusable office egress addresses", + allowed_cidrs=["203.0.113.7/32"], + ) + api = CurrentWorkspaceNetworkAccessGroupsApi() + + with _application_services(account_application_services): + body, status = unwrap(api.post)( + api, + req_data=request_payload, + request_context=_request_context(), + ) + + mock_create_group.assert_called_once_with( + _request_context(), + name="Office network", + description="Reusable office egress addresses", + allowed_cidrs=["203.0.113.7/32"], + ) + assert status == 201 + assert body["group"]["id"] == GROUP_ID + + +def test_group_detail_update_and_delete_forward_path_and_query_values( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mock_get_group = mocker.patch.object(service, "get_group", return_value=_group_payload()) + mock_update_group = mocker.patch.object(service, "update_group", return_value=_group_payload()) + mock_delete_group = mocker.patch.object(service, "delete_group", return_value=True) + update_payload = NetworkAccessGroupUpdatePayload( + name="Office network", + description="Updated", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ) + api = CurrentWorkspaceNetworkAccessGroupApi() + + with _application_services(account_application_services): + get_result = unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + update_result = unwrap(api.put)( + api, + req_data=update_payload, + request_context=_request_context(), + group_id=UUID(GROUP_ID), + ) + delete_result = unwrap(api.delete)( + api, + req_data=NetworkAccessGroupDeleteQuery(expected_version=2), + request_context=_request_context(), + group_id=UUID(GROUP_ID), + ) + + mock_get_group.assert_called_once_with(_request_context(), group_id=GROUP_ID) + mock_update_group.assert_called_once_with( + _request_context(), + group_id=GROUP_ID, + name="Office network", + description="Updated", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ) + mock_delete_group.assert_called_once_with(_request_context(), group_id=GROUP_ID, expected_version=2) + assert get_result["group"]["id"] == GROUP_ID + assert update_result["group"]["version"] == 2 + assert delete_result == {"deleted": True} + + +def test_app_get_and_put_forward_app_id_without_orm_models( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mock_get_app_binding = mocker.patch.object( + service, + "get_app_binding", + return_value=NetworkAccessAppConfig( + TENANT_ID, + APP_ID, + True, + False, + None, + ("webapp", "service_api", "mcp"), + ), + ) + mock_update_app_binding = mocker.patch.object( + service, + "update_app_binding", + return_value=NetworkAccessBindingUpdate( + _binding_payload(), + True, + ("webapp", "service_api", "mcp"), + ), + ) + request_payload = AppNetworkAccessGroupUpdatePayload( + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=2, + ) + api = AppNetworkAccessGroupApi() + + with _application_services(account_application_services): + get_result = unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + put_result = unwrap(api.put)( + api, + req_data=request_payload, + request_context=_request_context(), + app_id=UUID(APP_ID), + ) + + mock_get_app_binding.assert_called_once_with(_request_context(), app_id=APP_ID) + mock_update_app_binding.assert_called_once_with( + _request_context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=2, + ) + assert get_result["binding"] is None + assert get_result["effective_enabled"] is False + assert put_result["binding"]["version"] == 3 + assert put_result["effective_enabled"] is True + + +def test_app_status_serializes_only_entry_state( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mock_get_app_status = mocker.patch.object( + service, "get_app_status", return_value=NetworkAccessAppStatus(True, True, True, 2, 4) + ) + api = AppNetworkAccessGroupStatusApi() + + with _application_services(account_application_services): + result = unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + + assert result == { + "entitled": True, + "configured": True, + "enabled": True, + "covered_count": 2, + "available_count": 4, + } + mock_get_app_status.assert_called_once_with(_request_context(), app_id=APP_ID) + + +@pytest.mark.parametrize("role", ["normal", "owner", "admin", "editor"]) +@pytest.mark.parametrize( + ("app_mode", "covered_count", "available_count"), + [ + ("workflow", 3, 4), + ("advanced-chat", 2, 3), + ("chat", 2, 3), + ("completion", 2, 3), + ("agent-chat", 2, 3), + ("agent", 1, 2), + ], +) +def test_app_status_uses_minimal_upstream_route_and_filters_scopes_for_current_mode( + account_application_services: ApplicationServices, + role: str, + app_mode: str, + covered_count: int, + available_count: int, +) -> None: + response, requests = _typed_status_http_response( + account_application_services, + { + "entitled": True, + "configured": True, + "enabled": True, + "accessPoints": ["webapp", "mcp", "trigger", "future_scope", "webapp"], + "group_id": GROUP_ID, + "allowed_cidrs": ["203.0.113.7/32"], + }, + role=role, + app_mode=app_mode, + ) + + assert response.status_code == 200 + assert response.get_json() == { + "entitled": True, + "configured": True, + "enabled": True, + "covered_count": covered_count, + "available_count": available_count, + } + assert len(requests) == 1 + assert requests[0].method == "GET" + assert requests[0].url.path == f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status" + assert dict(requests[0].url.params) == {"actor_account_id": ACCOUNT_ID} + assert requests[0].headers["Billing-Api-Secret-Key"] == "synthetic" + + +@pytest.mark.parametrize( + ("wire", "paid", "expected"), + [ + ({}, True, {"entitled": False, "configured": False, "enabled": False, "covered_count": 0}), + ({"entitled": True}, True, {"entitled": True, "configured": False, "enabled": False, "covered_count": 0}), + ( + {"entitled": True, "configured": True, "access_points": ["webapp"]}, + True, + {"entitled": True, "configured": True, "enabled": False, "covered_count": 1}, + ), + ( + {"entitled": True, "configured": True, "enabled": True, "access_points": ["webapp"]}, + False, + {"entitled": False, "configured": True, "enabled": True, "covered_count": 1}, + ), + ], +) +def test_app_status_preserves_saved_state_and_applies_local_entitlement( + account_application_services: ApplicationServices, + wire: Mapping[str, object], + paid: bool, + expected: dict[str, object], +) -> None: + response, _requests = _typed_status_http_response(account_application_services, wire, paid=paid) + + assert response.status_code == 200 + assert response.get_json() == {**expected, "available_count": 4} + + +@pytest.mark.parametrize("role", ["dataset_operator", "unknown", None]) +def test_app_status_rejects_disallowed_roles_before_upstream_read( + account_application_services: ApplicationServices, role: str | None +) -> None: + response, requests = _typed_status_http_response(account_application_services, {}, role=role) + + assert response.status_code == 403 + assert requests == [] + + +@pytest.mark.parametrize("upstream_status", [403, 404]) +def test_app_status_does_not_fall_back_to_full_binding_after_upstream_error( + account_application_services: ApplicationServices, upstream_status: int +) -> None: + response, requests = _typed_status_http_response(account_application_services, {}, upstream_status=upstream_status) + + assert response.status_code == upstream_status + assert len(requests) == 1 + assert requests[0].url.path.endswith("/network-access-group/status") + + +@pytest.mark.parametrize("field", ["entitled", "configured", "enabled"]) +@pytest.mark.parametrize("value", ["false", "true", 0, 1, None]) +def test_app_status_rejects_non_boolean_flags( + account_application_services: ApplicationServices, field: str, value: object +) -> None: + response, _requests = _typed_status_http_response(account_application_services, {field: value}) + + assert response.status_code == 502 + + +@pytest.mark.parametrize( + "wire", + [ + {"access_points": "webapp"}, + {"access_points": [1]}, + {"access_points": ["webapp"], "accessPoints": ["mcp"]}, + ], +) +def test_app_status_rejects_malformed_or_conflicting_scopes( + account_application_services: ApplicationServices, wire: Mapping[str, object] +) -> None: + response, _requests = _typed_status_http_response(account_application_services, wire) + + assert response.status_code == 502 + + +@pytest.mark.parametrize( + ("status_code", "expected_exception"), + [ + (400, BadRequest), + (401, ServiceUnavailable), + (403, Forbidden), + (404, NotFound), + (409, Conflict), + (500, ServiceUnavailable), + (418, BadGateway), + ], +) +def test_upstream_error_mapping(status_code: int, expected_exception: type[HTTPException]) -> None: + assert isinstance(_translate_upstream_error(NetworkAccessGroupUpstreamError(status_code)), expected_exception) + + +@pytest.mark.parametrize( + ("reason", "expected_message"), + [ + ("NETWORK_ACCESS_VERSION_CONFLICT", "changed"), + ("NETWORK_ACCESS_GROUP_NAME_CONFLICT", "An IP policy with this name already exists."), + ("NETWORK_ACCESS_GROUP_LIMIT", "reached"), + ], +) +def test_conflict_error_mapping_preserves_safe_actionable_reason(reason: str, expected_message: str) -> None: + error = _translate_upstream_error(NetworkAccessGroupUpstreamError(409, reason)) + assert isinstance(error, Conflict) + assert error.description is not None + assert expected_message in error.description + + +@pytest.mark.parametrize("mutation", ["create", "update"]) +def test_duplicate_policy_name_mutations_preserve_conflict_and_public_message( + account_application_services: ApplicationServices, mocker: MockerFixture, mutation: str +) -> None: + service = account_application_services.network_access_groups + upstream_error = NetworkAccessGroupUpstreamError(409, "NETWORK_ACCESS_GROUP_NAME_CONFLICT") + mock_create_group = mocker.patch.object(service, "create_group", side_effect=upstream_error) + mock_update_group = mocker.patch.object(service, "update_group", side_effect=upstream_error) + + def invoke_mutation() -> None: + if mutation == "create": + api = CurrentWorkspaceNetworkAccessGroupsApi() + unwrap(api.post)( + api, + req_data=NetworkAccessGroupCreatePayload(name="Office", allowed_cidrs=["203.0.113.7"]), + request_context=_request_context(), + ) + else: + detail_api = CurrentWorkspaceNetworkAccessGroupApi() + unwrap(detail_api.put)( + detail_api, + req_data=NetworkAccessGroupUpdatePayload( + name="Office", allowed_cidrs=["203.0.113.7"], expected_version=2 + ), + request_context=_request_context(), + group_id=UUID(GROUP_ID), + ) + + with _application_services(account_application_services), pytest.raises(Conflict) as exc_info: + invoke_mutation() + + assert exc_info.value.code == 409 + assert exc_info.value.description == "An IP policy with this name already exists." + assert exc_info.value.__cause__ is upstream_error + assert upstream_error.reason == "NETWORK_ACCESS_GROUP_NAME_CONFLICT" + assert mock_create_group.call_count == (1 if mutation == "create" else 0) + assert mock_update_group.call_count == (1 if mutation == "update" else 0) + + +def test_internal_secret_error_is_not_reported_as_tenant_input_failure() -> None: + assert isinstance( + _translate_upstream_error(NetworkAccessGroupUpstreamError(400, "INVALID_SECRET_KEY")), + ServiceUnavailable, + ) + + +@pytest.mark.parametrize( + ("service_error", "expected_exception", "expected_message"), + [ + (NetworkAccessGroupAccessDeniedError(), Forbidden, "workspace role"), + (NetworkAccessGroupEntitlementUnavailableError(), ServiceUnavailable, "temporarily unavailable"), + (NetworkAccessGroupAppNotFoundError(), AppNotFoundError, "not found"), + (NetworkAccessGroupUnsupportedAppModeError("channel"), BadRequest, "channel"), + (NetworkAccessGroupUnsupportedAccessPointsError(["trigger"]), BadRequest, "trigger"), + (NetworkAccessGroupInvalidPolicyError(), ServiceUnavailable, "cannot be evaluated"), + (NetworkAccessGroupInvalidResponseError(), BadGateway, "invalid response"), + ], +) +def test_application_error_mapping( + service_error: NetworkAccessGroupError, + expected_exception: type[HTTPException], + expected_message: str, +) -> None: + error = _translate_service_error(service_error) + assert isinstance(error, expected_exception) + assert error.description is not None + assert expected_message in error.description.lower() + + +@pytest.mark.parametrize( + "mutation", + [ + CurrentWorkspaceNetworkAccessGroupsApi.post, + CurrentWorkspaceNetworkAccessGroupApi.put, + CurrentWorkspaceNetworkAccessGroupApi.delete, + AppNetworkAccessGroupApi.put, + ], +) +@pytest.mark.parametrize("paid", [False, True], ids=["unpaid", "paid"]) +def test_mutation_paid_plan_admission_precedes_payload_validation( + account_application_services: ApplicationServices, + mutation: Callable[..., object], + paid: bool, + config_overrides: Callable[..., None], +) -> None: + app = Flask(__name__) + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, LOGIN_DISABLED=False) + account = Account(name="Policy editor", email="editor@example.com", status=AccountStatus.ACTIVE) + account.id = ACCOUNT_ID + identity = AccountWithTenant(account, TENANT_ID) + method = mutation.__name__.upper() + + def mutation_route() -> Response: + mutation(object()) + raise AssertionError("An invalid payload must not reach the mutation body") + + app.add_url_rule("/mutation", view_func=mutation_route, methods=[method]) + + with ( + patch( + "controllers.console.workspace.network_access_group.application_services", + return_value=account_application_services, + ) as service_provider, + patch("libs.login._resolve_current_user", return_value=account), + patch("libs.login.check_csrf_token") as csrf_check, + patch("controllers.console.wraps.current_account_with_tenant", return_value=identity), + patch("controllers.console.flask_admission.current_account_with_tenant", return_value=identity), + patch("controllers.console.wraps.validate_request", wraps=validate_request) as validate_payload, + patch( + "controllers.console.wraps.is_cloud_edition_billing_paid_plan", + return_value=paid, + ) as is_paid_plan, + ): + response = app.test_client().open( + "/mutation", method=method, json={"name": "", "description": "", "allowed_cidrs": []} + ) + + assert response.status_code == (422 if paid else 403) + is_paid_plan.assert_called_once_with(TENANT_ID) + csrf_check.assert_called_once() + service_provider.assert_not_called() + if paid: + validate_payload.assert_called_once() + else: + assert b"This feature requires a paid plan." in response.data + validate_payload.assert_not_called() + + +def test_controller_maps_service_error_before_serialization( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mocker.patch.object(service, "list_groups", side_effect=NetworkAccessGroupAccessDeniedError) + + with _application_services(account_application_services), pytest.raises(Forbidden, match="workspace role"): + unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( + CurrentWorkspaceNetworkAccessGroupsApi(), + request_context=_request_context(), + ) + + +def test_invalid_service_response_is_bad_gateway( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + mocker.patch.object( + service, + "list_groups", + return_value={ + "tenant_id": TENANT_ID, + "entitled": True, + "groups": [{"allowed_cidrs": list[str]()}], + }, + ) + + with _application_services(account_application_services), pytest.raises(BadGateway): + unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( + CurrentWorkspaceNetworkAccessGroupsApi(), + request_context=_request_context(), + ) + + +@pytest.mark.parametrize( + "payload", + [ + {"name": "", "description": "", "allowed_cidrs": []}, + {"name": "group", "description": "", "allowed_cidrs": []}, + {"name": "group", "description": "", "allowed_cidrs": ["127.0.0.1"] * 101}, + ], +) +def test_create_payload_contract_rejects_invalid_shapes(payload: dict[str, object]) -> None: + with pytest.raises(ValidationError): + NetworkAccessGroupCreatePayload.model_validate(payload) + + +@pytest.mark.parametrize( + "payload", + [ + {"enabled": True, "group_id": None, "access_points": ["webapp"], "expected_version": 0}, + {"enabled": True, "group_id": GROUP_ID, "access_points": [], "expected_version": 0}, + { + "enabled": True, + "group_id": GROUP_ID, + "access_points": ["webapp", "webapp"], + "expected_version": 0, + }, + {"enabled": True, "group_id": GROUP_ID, "access_points": ["unknown"], "expected_version": 0}, + ], +) +def test_enabled_app_config_rejects_incomplete_or_invalid_access_points(payload: dict[str, object]) -> None: + with pytest.raises(ValidationError): + AppNetworkAccessGroupUpdatePayload.model_validate(payload) + + +def test_app_config_accepts_policy_id_alias_and_disabled_draft() -> None: + payload = AppNetworkAccessGroupUpdatePayload.model_validate( + { + "enabled": False, + "policy_id": GROUP_ID, + "accessPoints": ["webapp", "service_api"], + "expected_version": 3, + } + ) + + assert str(payload.group_id) == GROUP_ID + assert payload.access_points == ["webapp", "service_api"] + + +def test_current_ip_check_uses_trusted_resolver_after_service_admission_and_disables_caching( + account_application_services: ApplicationServices, + mocker: MockerFixture, + config_overrides: Callable[..., None], +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + + def check_current_ip( + _context: RequestContext, + *, + group_id: str, + client_ip_supplier: Callable[[], str], + ) -> NetworkAccessCurrentIPCheck: + assert group_id == GROUP_ID + return NetworkAccessCurrentIPCheck(client_ip_supplier(), True, 4) + + mocker.patch.object(service, "check_current_ip", side_effect=check_current_ip) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi() + + with ( + app.test_request_context( + headers={"X-Forwarded-For": "203.0.113.7"}, + environ_base={"REMOTE_ADDR": "172.18.0.3"}, + ), + _application_services(account_application_services), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip", + return_value="203.0.113.7", + ) as resolve_client_ip, + ): + result = unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + response = app.process_response(app.make_response(result)) + + assert result == {"client_ip": "203.0.113.7", "allowed": True, "policy_version": 4} + assert response.headers["Cache-Control"] == "no-store" + assert resolve_client_ip.call_args.args[1] == "172.18.0.0/16" + + +def test_current_ip_check_maps_unavailable_client_ip_to_503( + account_application_services: ApplicationServices, mocker: MockerFixture, config_overrides: Callable[..., None] +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + mocker.patch.object( + service, + "check_current_ip", + side_effect=lambda _context, *, group_id, client_ip_supplier: ( + client_ip_supplier() if group_id == GROUP_ID else None + ), + ) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi() + + with ( + app.test_request_context(environ_base={"REMOTE_ADDR": "172.18.0.3"}), + _application_services(account_application_services), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip", + side_effect=NetworkAccessClientIPUnavailableError, + ), + pytest.raises(ServiceUnavailable, match="Current client IP is unavailable"), + ): + unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + + +def test_current_ip_check_fails_closed_when_trusted_proxy_config_is_empty( + account_application_services: ApplicationServices, + mocker: MockerFixture, + config_overrides: Callable[..., None], +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="") + mocker.patch.object( + service, + "check_current_ip", + side_effect=lambda _context, *, group_id, client_ip_supplier: ( + client_ip_supplier() if group_id == GROUP_ID else None + ), + ) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPCheckApi() + + with ( + app.test_request_context(environ_base={"REMOTE_ADDR": "172.18.0.3"}), + _application_services(account_application_services), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip" + ) as resolve_client_ip, + pytest.raises(ServiceUnavailable, match="Current client IP is unavailable"), + ): + unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID)) + + resolve_client_ip.assert_not_called() + + +def test_group_response_rejects_enforcing_count_greater_than_used_by_count( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + service = account_application_services.network_access_groups + group = replace(_group_payload(app_ids=[APP_ID]), enforcing_count=2) + mocker.patch.object(service, "list_groups", return_value=NetworkAccessGroupList(TENANT_ID, True, (group,))) + + with _application_services(account_application_services), pytest.raises(BadGateway, match="Invalid response"): + unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)( + CurrentWorkspaceNetworkAccessGroupsApi(), + request_context=_request_context(), + ) + + +@pytest.mark.parametrize( + ("peer", "forwarded", "expected"), + [ + ("172.18.0.3", "203.0.113.7", "203.0.113.7"), + ("172.18.0.3", "2001:db8::42", "2001:db8::42"), + ("172.18.0.3", "::ffff:203.0.113.7", "203.0.113.7"), + ("203.0.113.7", "192.0.2.1", "203.0.113.7"), + ("172.18.0.3", "192.0.2.1, 203.0.113.7, 172.18.0.4", "203.0.113.7"), + ], + ids=["ipv4", "ipv6", "mapped-ipv6", "untrusted-peer-spoof", "trusted-proxy-appended-peer"], +) +def test_current_ip_read_uses_trusted_resolver_and_ignores_client_supplied_ip( + account_application_services: ApplicationServices, + mocker: MockerFixture, + config_overrides: Callable[..., None], + peer: str, + forwarded: str, + expected: str, +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + mock_get_group = mocker.patch.object(service, "get_group") + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + mock_get_current_ip = mocker.patch.object( + service, "get_current_ip", side_effect=lambda _context, *, client_ip_supplier: client_ip_supplier() + ) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPApi() + + with ( + app.test_request_context( + "/workspaces/current/network-access-groups/current-ip?client_ip=192.0.2.1&group_id=ignored", + headers={ + "X-Forwarded-For": forwarded, + "CF-Connecting-IP": "192.0.2.1", + "X-Real-IP": "192.0.2.1", + "Forwarded": "for=192.0.2.1", + }, + json={"client_ip": "192.0.2.1"}, + environ_base={"REMOTE_ADDR": peer}, + ), + _application_services(account_application_services), + ): + result = unwrap(api.get)(api, request_context=_request_context()) + response = app.process_response(app.make_response(result)) + + assert result == {"client_ip": expected} + assert response.headers["Cache-Control"] == "no-store" + assert mock_get_current_ip.call_args.args == (_request_context(),) + assert set(mock_get_current_ip.call_args.kwargs) == {"client_ip_supplier"} + mock_get_group.assert_not_called() + + +@pytest.mark.parametrize( + ("trusted_proxies", "forwarded"), + [ + ("", "203.0.113.7"), + ("not-a-cidr", "203.0.113.7"), + ("172.18.0.0/16", None), + ("172.18.0.0/16", "malformed"), + ("172.18.0.0/16", "172.18.0.4"), + ], + ids=["missing-proxy-config", "invalid-proxy-config", "missing-xff", "invalid-xff", "all-trusted-hops"], +) +def test_current_ip_read_fails_closed_with_no_store_on_503( + account_application_services: ApplicationServices, + mocker: MockerFixture, + config_overrides: Callable[..., None], + trusted_proxies: str, + forwarded: str | None, +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + config_overrides(NETWORK_ACCESS_TRUSTED_PROXY_CIDRS=trusted_proxies) + mocker.patch.object( + service, "get_current_ip", side_effect=lambda _context, *, client_ip_supplier: client_ip_supplier() + ) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPApi() + + with ( + app.test_request_context( + headers={"X-Forwarded-For": forwarded} if forwarded is not None else {}, + environ_base={"REMOTE_ADDR": "172.18.0.3"}, + ), + _application_services(account_application_services), + ): + with pytest.raises(ServiceUnavailable, match="Current client IP is unavailable") as exc_info: + unwrap(api.get)(api, request_context=_request_context()) + response = app.process_response(app.make_response(exc_info.value.get_response())) + + assert response.status_code == 503 + assert response.headers["Cache-Control"] == "no-store" + + +def test_current_ip_read_maps_role_denial_without_resolving_ip( + account_application_services: ApplicationServices, + mocker: MockerFixture, +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + mocker.patch.object(service, "get_current_ip", side_effect=NetworkAccessGroupAccessDeniedError) + api = CurrentWorkspaceNetworkAccessGroupCurrentIPApi() + + with ( + app.test_request_context(), + _application_services(account_application_services), + patch( + "controllers.console.workspace.network_access_group.resolve_network_access_client_ip" + ) as resolve_client_ip, + ): + with pytest.raises(Forbidden, match="workspace role") as exc_info: + unwrap(api.get)(api, request_context=_request_context()) + response = app.process_response(app.make_response(exc_info.value.get_response())) + + resolve_client_ip.assert_not_called() + assert response.status_code == 403 + assert response.headers["Cache-Control"] == "no-store" + + +@pytest.mark.parametrize("edition", [DeploymentEdition.COMMUNITY, DeploymentEdition.ENTERPRISE]) +def test_current_ip_read_is_cloud_only( + account_application_services: ApplicationServices, + mocker: MockerFixture, + config_overrides: Callable[..., None], + edition: DeploymentEdition, +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + mock_get_current_ip = mocker.patch.object(service, "get_current_ip") + config_overrides(DEPLOYMENT_EDITION=edition, LOGIN_DISABLED=False) + account = Account(name="Policy editor", email="editor@example.com", status=AccountStatus.ACTIVE) + account.id = ACCOUNT_ID + + with ( + app.test_request_context(), + _application_services(account_application_services), + patch("controllers.console.wraps._is_setup_completed", return_value=True), + patch("libs.login._resolve_current_user", return_value=account), + patch("libs.login.check_csrf_token") as csrf_check, + patch( + "controllers.console.wraps.current_account_with_tenant", return_value=AccountWithTenant(account, TENANT_ID) + ), + pytest.raises(NotFound), + ): + CurrentWorkspaceNetworkAccessGroupCurrentIPApi().get() + + csrf_check.assert_called_once() + mock_get_current_ip.assert_not_called() + + +def test_current_ip_read_requires_authenticated_account( + account_application_services: ApplicationServices, mocker: MockerFixture, config_overrides: Callable[..., None] +) -> None: + app = Flask(__name__) + service = account_application_services.network_access_groups + mock_get_current_ip = mocker.patch.object(service, "get_current_ip") + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, LOGIN_DISABLED=False) + login_manager = DifyLoginManager() + login_manager.unauthorized_handler(lambda: Response(status=401)) + + with ( + app.test_request_context(), + _application_services(account_application_services), + patch("libs.login._resolve_current_user", return_value=None), + patch("libs.login._get_login_manager", return_value=login_manager), + ): + response = CurrentWorkspaceNetworkAccessGroupCurrentIPApi().get() + + assert isinstance(response, Response) + assert response.status_code == 401 + mock_get_current_ip.assert_not_called() + + +def test_current_ip_read_response_schema_exposes_only_client_ip() -> None: + schema = NetworkAccessGroupCurrentIPResponse.model_json_schema(mode="serialization") + + assert schema["required"] == ["client_ip"] + assert set(schema["properties"]) == {"client_ip"} + assert schema["properties"]["client_ip"]["type"] == "string" + + +@pytest.mark.parametrize("group_field", ["group_id", "groupId", "policy_id", "policyId"]) +@pytest.mark.parametrize("stale_only", [False, True]) +def test_typed_gateway_service_controller_pipeline_preserves_binding_configuration( + account_application_services: ApplicationServices, group_field: str, stale_only: bool +) -> None: + wire = { + "tenantId": TENANT_ID, + "appId": APP_ID, + "entitled": True, + "effectiveEnabled": True, + "binding": { + "id": BINDING_ID, + "tenantId": TENANT_ID, + "appId": APP_ID, + "enabled": True, + group_field: GROUP_ID, + "accessPoints": ["future_scope"] if stale_only else ["webapp"], + "version": "3", + "createdAt": "2026-08-21T00:00:00Z", + "updatedAt": "2026-08-21T00:00:00Z", + }, + } + response = _typed_binding_http_response(account_application_services, wire) + assert response.status_code == 200 + body = response.get_json() + assert body["binding"]["enabled"] is True + assert body["binding"]["group_id"] == GROUP_ID + assert body["binding"]["version"] == 3 + assert body["binding"]["access_points"] == ([] if stale_only else ["webapp"]) + assert body["effective_enabled"] is not stale_only + assert wire["binding"]["accessPoints"] == (["future_scope"] if stale_only else ["webapp"]) + + +def test_malformed_upstream_response_maps_to_http_502_before_business_truthiness( + account_application_services: ApplicationServices, +) -> None: + response = _typed_binding_http_response( + account_application_services, + { + "tenantId": TENANT_ID, + "appId": APP_ID, + "entitled": "false", + "effectiveEnabled": False, + }, + ) + assert response.status_code == 502 + + +def _typed_binding_http_response(services: ApplicationServices, wire: Mapping[str, object]) -> TestResponse: + app = Flask(__name__) + app.testing = True + apps = SQLAlchemyNetworkAccessGroupAppRepository(session_factory=sessionmaker()) + memberships = WorkspaceRepository(session_factory=sessionmaker()) + entitlement = BillingNetworkAccessGroupEntitlementGateway() + with httpx.Client(transport=httpx.MockTransport(lambda _request: httpx.Response(200, json=wire))) as client: + gateway = NetworkAccessGroupGateway( + base_url="https://test.invalid", + fallback_base_url="", + secret_key="synthetic", + http_client=client, + ) + service = NetworkAccessGroupService( + control_plane=gateway, apps=apps, memberships=memberships, entitlement=entitlement + ) + api = AppNetworkAccessGroupApi() + + @app.get("/binding") + def binding() -> dict[str, object]: + return unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + + with ( + _application_services(replace(services, network_access_groups=service)), + patch.object( + apps, + "get_manageable_app", + return_value=NetworkAccessGroupAppRecord(APP_ID, "workflow", "Test", None, None, None), + ), + patch.object(memberships, "get_account_role", return_value="owner"), + patch.object(entitlement, "is_paid_plan", return_value=True), + ): + return app.test_client().get("/binding") + + +def _typed_status_http_response( + services: ApplicationServices, + wire: Mapping[str, object], + *, + role: str | None = "normal", + app_mode: str = "workflow", + paid: bool = True, + upstream_status: int = 200, +) -> tuple[TestResponse, list[httpx.Request]]: + app = Flask(__name__) + app.testing = True + apps = SQLAlchemyNetworkAccessGroupAppRepository(session_factory=sessionmaker()) + memberships = WorkspaceRepository(session_factory=sessionmaker()) + entitlement = BillingNetworkAccessGroupEntitlementGateway() + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.url.path != f"/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group/status": + return httpx.Response(403, json={"reason": "FORBIDDEN"}) + return httpx.Response(upstream_status, json=wire) + + with httpx.Client(transport=httpx.MockTransport(respond)) as client: + gateway = NetworkAccessGroupGateway( + base_url="https://test.invalid", + fallback_base_url="", + secret_key="synthetic", + http_client=client, + ) + service = NetworkAccessGroupService( + control_plane=gateway, apps=apps, memberships=memberships, entitlement=entitlement + ) + api = AppNetworkAccessGroupStatusApi() + + @app.get("/status") + def status() -> dict[str, object]: + return unwrap(api.get)(api, request_context=_request_context(), app_id=UUID(APP_ID)) + + with ( + _application_services(replace(services, network_access_groups=service)), + patch.object( + apps, + "get_manageable_app", + return_value=NetworkAccessGroupAppRecord(APP_ID, app_mode, "Test", None, None, None), + ), + patch.object(memberships, "get_account_role", return_value=role), + patch.object(entitlement, "is_paid_plan", return_value=paid), + ): + return app.test_client().get("/status"), requests diff --git a/api/tests/unit_tests/controllers/mcp/test_mcp.py b/api/tests/unit_tests/controllers/mcp/test_mcp.py index 127552cdd5de31..088f085aa52d96 100644 --- a/api/tests/unit_tests/controllers/mcp/test_mcp.py +++ b/api/tests/unit_tests/controllers/mcp/test_mcp.py @@ -12,6 +12,8 @@ import pytest from flask import Flask, Response from pydantic import ValidationError +from sqlalchemy import inspect +from sqlalchemy.orm import Session, sessionmaker import controllers.mcp.mcp as module from models.engine import db @@ -37,7 +39,8 @@ def app() -> Iterator[Flask]: Workflow.__table__, ): table.create(db.engine) - yield app + with patch.object(module.session_factory, "get_session_maker", return_value=sessionmaker(db.engine)): + yield app @pytest.fixture(autouse=True) @@ -230,13 +233,19 @@ def test_inactive_server(self): post_fn = unwrap(api.post) - with pytest.raises(module.MCPRequestError): - post_fn("server-1") + response = post_fn("server-1") + assert response.status_code == 404 + assert response.get_json()["error"]["message"] == "Server Not Found" - def test_invalid_payload(self): + def test_invalid_payload(self, mocker): fake_payload({"invalid": "data"}) api = module.MCPAppApi() + mocker.patch.object( + api, + "_get_mcp_server_and_app", + return_value=(_server(module.AppMCPServerStatus.ACTIVE), _app(module.AppMode.WORKFLOW)), + ) post_fn = unwrap(api.post) with pytest.raises(ValidationError): @@ -263,7 +272,7 @@ def test_unknown_method_without_request_id_is_ignored(self): assert response.status_code == 202 assert response.data == b"" - def test_server_not_found(self): + def test_server_not_found(self, mocker): """Test when MCP server doesn't exist""" fake_payload( { @@ -279,17 +288,15 @@ def test_server_not_found(self): ) api = module.MCPAppApi() - api._get_mcp_server_and_app = MagicMock( - side_effect=module.MCPRequestError(module.mcp_types.INVALID_REQUEST, "Server Not Found") - ) + mocker.patch.object(api, "_get_mcp_server_and_app", side_effect=module.MCPServerNotFoundError()) post_fn = unwrap(api.post) - with pytest.raises(module.MCPRequestError) as exc_info: - post_fn("server-1") - assert "Server Not Found" in str(exc_info.value) + response = post_fn("server-1") + assert response.status_code == 404 + assert response.get_json()["error"]["message"] == "Server Not Found" - def test_app_not_found(self): + def test_app_not_found(self, mocker): """Test when app associated with server doesn't exist""" fake_payload( { @@ -305,15 +312,13 @@ def test_app_not_found(self): ) api = module.MCPAppApi() - api._get_mcp_server_and_app = MagicMock( - side_effect=module.MCPRequestError(module.mcp_types.INVALID_REQUEST, "App Not Found") - ) + mocker.patch.object(api, "_get_mcp_server_and_app", side_effect=module.MCPServerNotFoundError()) post_fn = unwrap(api.post) - with pytest.raises(module.MCPRequestError) as exc_info: - post_fn("server-1") - assert "App Not Found" in str(exc_info.value) + response = post_fn("server-1") + assert response.status_code == 404 + assert response.get_json()["error"]["message"] == "Server Not Found" def test_app_unavailable_no_workflow(self): """Test when app has no workflow (ADVANCED_CHAT mode)""" @@ -556,6 +561,61 @@ def _tools_call_payload() -> dict[str, object]: } +def test_execution_reloads_attached_orm_objects_after_identity_preflight( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + app_model = _app(module.AppMode.CHAT, with_model_config=True) + server = _server(module.AppMCPServerStatus.ACTIVE) + db.session.add_all([app_model, server]) + db.session.commit() + api = module.MCPAppApi() + lookup = api._get_mcp_server_and_app + snapshots: list[tuple[module.AppMCPServer, module.App]] = [] + + def observe_lookup(code: str, session: Session) -> tuple[module.AppMCPServer, module.App]: + pair = lookup(code, session) + snapshots.append(pair) + return pair + + def process( + _mcp_request: module.mcp_types.ClientRequest | module.mcp_types.ClientNotification, + _request_id: int | str | None, + fresh_app: module.App, + fresh_server: module.AppMCPServer, + _form: list[module.VariableEntity], + session: Session, + _protocol: str, + ) -> Response: + old_server, old_app = snapshots[0] + assert inspect(old_server).detached + assert inspect(old_app).detached + assert fresh_server is not old_server + assert fresh_app is not old_app + assert inspect(fresh_server).session is session + assert inspect(fresh_app).session is session + return Response("ok") + + monkeypatch.setattr(api, "_get_mcp_server_and_app", observe_lookup) + monkeypatch.setattr(api, "_process_mcp_message", process) + fake_payload({"jsonrpc": "2.0", "method": "ping", "id": 1}) + with app.test_request_context("/"): + response = api.post("server-1") + assert response.status_code == 200 + assert len(snapshots) == 2 + + +@pytest.mark.usefixtures("app") +def test_identity_query_does_not_accept_a_server_bound_to_another_tenants_app() -> None: + app_model = _app(module.AppMode.CHAT) + server = _server(module.AppMCPServerStatus.ACTIVE) + server.tenant_id = str(uuid4()) + db.session.add_all([app_model, server]) + db.session.commit() + with sessionmaker(db.engine).begin() as session: + with pytest.raises(module.MCPServerNotFoundError): + module.MCPAppApi()._get_mcp_server_and_app("server-1", session) + + class TestMCPProtocolVersionNegotiationApi: """MCP protocol version negotiation exercised through the HTTP controller layer. diff --git a/api/tests/unit_tests/controllers/mcp/test_mcp_request_errors.py b/api/tests/unit_tests/controllers/mcp/test_mcp_request_errors.py index 4f1789490aa0ae..c6aa7bb035de65 100644 --- a/api/tests/unit_tests/controllers/mcp/test_mcp_request_errors.py +++ b/api/tests/unit_tests/controllers/mcp/test_mcp_request_errors.py @@ -39,7 +39,7 @@ def client(app: Flask) -> FlaskClient: return app.test_client() -@pytest.fixture +@pytest.fixture(autouse=True) def published_server(sqlite_session: Session) -> AppMCPServer: app_id = str(uuid4()) tenant_id = str(uuid4()) diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 519ac21510a097..0bb29bb21a0666 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -167,7 +167,7 @@ def test_invalid_auth_scheme(self, app: Flask): # Act & Assert with pytest.raises(Unauthorized) as exc_info: validate_and_get_api_token("app") - assert "Authorization scheme must be 'Bearer'" in str(exc_info.value) + assert "Authorization header must be provided and start with 'Bearer'" in str(exc_info.value) @patch("controllers.service_api.wraps.record_token_usage") @patch("controllers.service_api.wraps.ApiTokenCache") @@ -224,6 +224,16 @@ def app(self): app.config["TESTING"] = True return app + @pytest.mark.parametrize("separator", ["\u00a0", "\u001c", "\u000b", "\u2003"]) + def test_non_http_whitespace_never_resolves_resource_token(self, app: Flask, separator: str) -> None: + with ( + app.test_request_context("/", headers={"Authorization": f"Bearer{separator}dify_rt_token"}), + patch.object(wraps_module, "application_services") as services, + pytest.raises(Unauthorized), + ): + validate_app_token(lambda: "ok")() + services.assert_not_called() + @patch("controllers.service_api.wraps.user_logged_in") @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.current_app") diff --git a/api/tests/unit_tests/controllers/trigger/test_trigger.py b/api/tests/unit_tests/controllers/trigger/test_trigger.py index c7079d332c1d28..d42f4249465da7 100644 --- a/api/tests/unit_tests/controllers/trigger/test_trigger.py +++ b/api/tests/unit_tests/controllers/trigger/test_trigger.py @@ -2,7 +2,6 @@ from unittest.mock import patch import pytest -from werkzeug.exceptions import NotFound import controllers.trigger.trigger as module @@ -23,8 +22,9 @@ def mock_jsonify(): class TestTriggerEndpoint: def test_invalid_uuid(self): - with pytest.raises(NotFound): - module.trigger_endpoint(INVALID_UUID) + response = module.trigger_endpoint(INVALID_UUID) + assert response.status_code == 404 + assert response.data == b'{"error":"Endpoint not found"}' @patch.object(module.TriggerService, "process_endpoint") @patch.object(module.TriggerSubscriptionBuilderService, "process_builder_validation_endpoint") @@ -53,10 +53,9 @@ def test_no_handler_returns_response(self, mock_builder, mock_trigger): mock_trigger.return_value = None mock_builder.return_value = None - response, status = module.trigger_endpoint(VALID_UUID) - - assert status == 404 - assert response["error"] == "Endpoint not found" + response = module.trigger_endpoint(VALID_UUID) + assert response.status_code == 404 + assert response.data == b'{"error":"Endpoint not found"}' @patch.object(module.TriggerService, "process_endpoint", side_effect=ValueError("bad input")) def test_value_error(self, mock_trigger): diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py index 6c2cca8a8bea31..0e80e896b94452 100644 --- a/api/tests/unit_tests/controllers/trigger/test_webhook.py +++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py @@ -172,8 +172,9 @@ def test_rate_limited(self, mock_get): @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing")) def test_value_error_not_found(self, mock_get): - with pytest.raises(NotFound): - module.handle_webhook("wh-1") + response = module.handle_webhook("wh-1") + assert response.status_code == 404 + assert response.data == NotFound().get_body().encode() @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=RequestEntityTooLarge()) def test_request_entity_too_large(self, mock_get): @@ -250,8 +251,9 @@ def test_debug_bad_request(self, mock_extract, mock_get): @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing")) def test_debug_not_found(self, mock_get): - with pytest.raises(NotFound): - module.handle_webhook_debug("wh-1") + response = module.handle_webhook_debug("wh-1") + assert response.status_code == 404 + assert response.data == NotFound().get_body().encode() @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=RequestEntityTooLarge()) def test_debug_request_entity_too_large(self, mock_get): diff --git a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py index e48038d2e9a40d..42117e7fbe588e 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_file_upload.py @@ -9,6 +9,7 @@ import pytest from flask import Flask +from pytest_mock import MockerFixture import controllers.web.human_input_file_upload as upload_module from controllers.common.errors import ( @@ -28,6 +29,7 @@ InvalidUploadTokenForbiddenError, InvalidUploadTokenUnauthorizedError, ) +from extensions.ext_application_services import ApplicationServices from models import Account from models.model import UploadFile from services.errors.file import ( @@ -147,6 +149,25 @@ def test_local_upload_requires_authorization_before_reading_files(app: Flask) -> HumanInputFileUploadApi().post() +@pytest.mark.parametrize("separator", ["\xa0", "\x1c", "\v", "\u2003"]) +def test_non_http_whitespace_cannot_bypass_gateway_upload_identity( + monkeypatch: pytest.MonkeyPatch, app: Flask, separator: str +) -> None: + service = MagicMock() + _patch_upload_service(monkeypatch, service) + with app.test_request_context( + "/api/human-input-forms/files", + method="POST", + headers={"Authorization": f"Bearer{separator}hitl_upload_token-1"}, + content_type="multipart/form-data", + ): + with pytest.raises(InvalidUploadTokenUnauthorizedError): + HumanInputFileUploadApi().post() + service.validate_upload_token.assert_not_called() + service.upload_local_file.assert_not_called() + service.upload_remote_file.assert_not_called() + + def test_local_upload_delegates_to_human_input_upload_service(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None: service = MagicMock() context = _upload_context() @@ -314,14 +335,16 @@ def test_upload_maps_file_service_errors( service_error: Exception, http_error: type[Exception], remote: bool, + mocker: MockerFixture, + account_application_services: ApplicationServices, ) -> None: - service = MagicMock() - service.validate_upload_token.return_value = _upload_context() + service = account_application_services.human_input_file_uploads + mocker.patch.object(service, "validate_upload_token", return_value=_upload_context()) if remote: - service.upload_remote_file.side_effect = service_error + mocker.patch.object(service, "upload_remote_file", side_effect=service_error) data = {"url": "https://example.com/file.txt"} else: - service.upload_local_file.side_effect = service_error + mocker.patch.object(service, "upload_local_file", side_effect=service_error) data = {"file": (BytesIO(b"content"), "sample.txt")} _patch_upload_service(monkeypatch, service) diff --git a/api/tests/unit_tests/controllers/web/test_public_app_admission.py b/api/tests/unit_tests/controllers/web/test_public_app_admission.py new file mode 100644 index 00000000000000..427841eed16d19 --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_public_app_admission.py @@ -0,0 +1,358 @@ +"""Public bootstrap admission with real SQL repositories and the canonical hook.""" + +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from flask import Flask +from flask.testing import FlaskClient +from pytest_mock import MockerFixture +from sqlalchemy import func, select +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from controllers.web import app as app_controller +from controllers.web import bp, login, passport, site, wraps +from enums import DeploymentEdition +from extensions.ext_application_services import ApplicationServices +from libs.passport import PassportService +from models.account import Tenant +from models.enums import EndUserType +from models.model import App, AppMode, CustomizeTokenStrategy, EndUser, Message, Site +from models.workflow import Workflow, WorkflowRun, WorkflowType +from repositories.app_definition_query_repository import AppDefinitionQueryRepository +from repositories.web_passport_repository import WebPassportRepository +from repositories.webapp_access_query_repository import WebAppAccessQueryRepository +from services.app_definition_query_service import AppDefinitionQueryService +from services.entities.feature_entities import FeatureModel +from services.file_service import FileService +from services.web_app_runtime_query_service import WebAppRuntimeQueryService +from services.web_authentication_adapters import PassportWebAppSessionGateway +from services.web_passport_gateways import PassportTokenGateway +from services.web_passport_service import WebPassportService +from tests.unit_tests.config_override import apply_config_overrides + + +@dataclass +class AdmissionHarness: + app: App + old_user: EndUser + client: FlaskClient + services: ApplicationServices + issue: MagicMock + auth_calls: tuple[MagicMock, ...] + mode_lookup: MagicMock + login_verify: MagicMock + session_verify: MagicMock + metadata_calls: tuple[MagicMock, ...] + access_repo: WebAppAccessQueryRepository + + +def _harness( + session: Session, + factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + base_services: ApplicationServices, + *, + published: bool, + missing: bool = False, +) -> AdmissionHarness: + app = App( + id=str(uuid4()), + tenant_id=str(uuid4()), + mode=AppMode.ADVANCED_CHAT, + name="Must not leak", + enable_site=True, + enable_api=True, + ) + old_user = EndUser( + id=str(uuid4()), + tenant_id=app.tenant_id, + app_id=app.id, + session_id="old", + is_anonymous=True, + type=EndUserType.BROWSER, + ) + if not missing: + tenant = Tenant(name="Private workspace") + tenant.id = app.tenant_id + session.add(tenant) + workflow = Workflow( + id=str(uuid4()), + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.CHAT, + version="published" if published else "draft", + graph='{"nodes":[]}', + features="{}", + created_by=str(uuid4()), + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + if published: + app.workflow_id = workflow.id + session.add_all( + [ + app, + old_user, + workflow, + Site( + app_id=app.id, + code="fixture-code", + title="Must not leak", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + ), + ] + ) + session.commit() + + access_repo = WebAppAccessQueryRepository(session_factory=factory) + passport_repo = WebPassportRepository(session_factory=factory, generate_session_id=lambda: "new-session") + access = base_services.webapp_access + monkeypatch.setattr(access, "_access", access_repo) + monkeypatch.setattr(access, "_webapp_auth_enabled", False) + mode_lookup = mocker.spy(access._policy, "get_access_mode") + auth = base_services.web_passport._auth + auth_calls = ( + mocker.spy(auth, "is_webapp_auth_enabled"), + mocker.spy(auth, "get_app_auth_type"), + ) + tokens = PassportTokenGateway(passport=PassportService()) + issue = mocker.patch.object(PassportService, "issue", return_value="new-passport") + mocker.patch.object( + PassportService, + "verify", + return_value={"app_id": app.id, "app_code": "fixture-code", "end_user_id": old_user.id}, + ) + app_sessions = PassportWebAppSessionGateway(sessions=access_repo, app_access=access) + session_verify = mocker.spy(app_sessions, "verify") + authentication = base_services.web_authentication + monkeypatch.setattr(authentication, "_app_access", access) + monkeypatch.setattr(authentication, "_app_sessions", app_sessions) + monkeypatch.setattr(authentication, "_private_app_access_enabled", False) + login_verify = mocker.spy(authentication._tokens, "verify_access_token") + definitions = AppDefinitionQueryRepository(session_factory=factory) + services = replace( + base_services, + webapp_access=access, + web_authentication=authentication, + web_passport=WebPassportService( + passports=passport_repo, + auth=auth, + tokens=tokens, + now=lambda: datetime.now(UTC), + access_token_expire_minutes=60, + ), + app_definitions=AppDefinitionQueryService(definitions=definitions, builtin_icon_url_prefix=""), + web_app_runtime=WebAppRuntimeQueryService( + runtime=definitions, + file_service=FileService(session_factory=factory), + workspace_features=lambda _: FeatureModel(), + files_url="https://files.example", + deployment_edition=DeploymentEdition.CLOUD, + ), + ) + metadata_calls = tuple( + mocker.spy(services.app_definitions, method) + for method in ( + "get_mode", + "get_parameters", + "get_public_parameters", + "get_tool_icons", + "get_summary", + "get_site_configuration", + ) + ) + (mocker.spy(services.web_app_runtime, "get_bootstrap"),) + for module in (app_controller, login, passport, site, wraps): + monkeypatch.setattr(module, "application_services", lambda: services) + monkeypatch.setattr(wraps, "extract_webapp_passport", lambda *_: "old-passport") + monkeypatch.setattr(login, "extract_webapp_passport", lambda *_: "old-passport") + monkeypatch.setattr(wraps.SystemFeatureService, "is_webapp_auth_enabled", lambda: False) + apply_config_overrides( + monkeypatch, + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, + NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16", + ) + flask = Flask(__name__) + flask.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + flask.register_blueprint(bp) + return AdmissionHarness( + app=app, + old_user=old_user, + client=flask.test_client(), + services=services, + issue=issue, + auth_calls=auth_calls, + mode_lookup=mode_lookup, + login_verify=login_verify, + session_verify=session_verify, + metadata_calls=metadata_calls, + access_repo=access_repo, + ) + + +ROUTES = ( + "/webapp/access-mode?appCode=fixture-code", + "/login/status?app_code=fixture-code", + "/passport?user_id=new-session", + "/site", + "/meta", + "/parameters", +) + + +@pytest.mark.parametrize("route", ROUTES) +@pytest.mark.parametrize("missing", [False, True], ids=["unpublished", "missing"]) +def test_all_bootstrap_routes_share_404_before_user_or_metadata_side_effects( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + account_application_services: ApplicationServices, + route: str, + missing: bool, +) -> None: + h = _harness( + sqlite_session, + sqlite_session_factory, + monkeypatch, + mocker, + account_application_services, + published=False, + missing=missing, + ) + response = h.client.get( + "/api" + route, + headers={"X-App-Code": "fixture-code"}, + environ_overrides={"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": "203.0.113.42"}, + ) + assert response.status_code == 404 + assert ( + response.data == b'{"client_ip":"203.0.113.42","code":"app_not_found","message":"App not found.","status":404}' + ) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + assert all(call.call_count == 0 for call in h.metadata_calls) + assert all(call.call_count == 0 for call in h.auth_calls) + h.mode_lookup.assert_not_called() + h.issue.assert_not_called() + h.login_verify.assert_not_called() + h.session_verify.assert_not_called() + assert sqlite_session.scalar(select(func.count()).select_from(EndUser)) == (0 if missing else 1) + assert sqlite_session.scalar(select(func.count()).select_from(WorkflowRun)) == 0 + assert sqlite_session.scalar(select(func.count()).select_from(Message)) == 0 + + +@pytest.mark.parametrize("route", ROUTES) +def test_published_bootstrap_remains_successful_with_normal_passport_behavior( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + route: str, + mocker: MockerFixture, + account_application_services: ApplicationServices, +) -> None: + h = _harness( + sqlite_session, sqlite_session_factory, monkeypatch, mocker, account_application_services, published=True + ) + response = h.client.get("/api" + route, headers={"X-App-Code": "fixture-code"}) + assert response.status_code == 200 + if route.startswith("/passport"): + assert response.get_json() == {"access_token": "new-passport"} + h.issue.assert_called_once() + assert sqlite_session.scalar(select(func.count()).select_from(EndUser)) == 2 + else: + h.issue.assert_not_called() + assert sqlite_session.scalar(select(func.count()).select_from(EndUser)) == 1 + if route.startswith("/webapp"): + assert response.get_json() == {"accessMode": "public"} + elif route.startswith("/login"): + assert response.get_json() == {"logged_in": True, "app_logged_in": True} + h.session_verify.assert_called_once_with(token="old-passport", app_code="fixture-code", user_id=None) + h.login_verify.assert_not_called() + elif route == "/site": + assert response.get_json()["site"]["title"] == "Must not leak" + elif route == "/meta": + assert response.get_json() == {"tool_icons": {}} + else: + assert "user_input_form" in response.get_json() + + +@pytest.mark.parametrize("route", ["/site", "/meta", "/parameters"]) +def test_old_passport_cannot_read_metadata_after_published_version_is_removed( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + route: str, + mocker: MockerFixture, + account_application_services: ApplicationServices, +) -> None: + h = _harness( + sqlite_session, sqlite_session_factory, monkeypatch, mocker, account_application_services, published=True + ) + assert h.access_repo.is_app_available(h.app.id) + h.app.workflow_id = None + sqlite_session.commit() + response = h.client.get( + "/api" + route, headers={"X-App-Code": "fixture-code"}, environ_overrides={"REMOTE_ADDR": "203.0.113.42"} + ) + assert response.status_code == 404 + assert response.get_json()["code"] == "app_not_found" + assert response.get_json()["client_ip"] == "203.0.113.42" + assert all(call.call_count == 0 for call in h.metadata_calls) + + +@pytest.mark.parametrize("changed_owner", ["site-app", "end-user-app", "end-user-tenant"]) +def test_old_passport_cannot_cross_persisted_app_or_tenant_owner_chain( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + account_application_services: ApplicationServices, + changed_owner: str, +) -> None: + h = _harness( + sqlite_session, sqlite_session_factory, monkeypatch, mocker, account_application_services, published=True + ) + if changed_owner == "site-app": + persisted_site = sqlite_session.scalar(select(Site).where(Site.app_id == h.app.id)) + assert persisted_site is not None + persisted_site.app_id = str(uuid4()) + elif changed_owner == "end-user-app": + h.old_user.app_id = str(uuid4()) + else: + h.old_user.tenant_id = str(uuid4()) + sqlite_session.commit() + response = h.client.get("/api/site", headers={"X-App-Code": "fixture-code"}) + assert response.status_code == 404 + assert response.get_json()["code"] == "app_not_found" + assert all(call.call_count == 0 for call in h.metadata_calls) + + +@pytest.mark.parametrize("route", ROUTES) +def test_real_database_failure_is_not_reclassified_as_unpublished( + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + route: str, + mocker: MockerFixture, + account_application_services: ApplicationServices, +) -> None: + h = _harness( + sqlite_session, sqlite_session_factory, monkeypatch, mocker, account_application_services, published=False + ) + failure = OperationalError("select", {}, RuntimeError("database unavailable")) + mocker.patch.object(Session, "scalar", side_effect=failure) + mocker.patch.object(Session, "execute", side_effect=failure) + response = h.client.get("/api" + route, headers={"X-App-Code": "fixture-code"}) + assert response.status_code == (503 if route.startswith(("/webapp", "/login")) else 500) + assert response.get_json()["code"] != "app_not_found" + assert "client_ip" not in response.get_json() + h.issue.assert_not_called() + h.login_verify.assert_not_called() + h.session_verify.assert_not_called() diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py index 8090a8707852d8..246ac935bf769c 100644 --- a/api/tests/unit_tests/controllers/web/test_site.py +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -2,14 +2,22 @@ from unittest.mock import MagicMock, patch import pytest -from werkzeug.exceptions import Forbidden +from flask import Blueprint, Flask +from pytest_mock import MockerFixture +from werkzeug.exceptions import Forbidden, ServiceUnavailable +from controllers.common.app_access_error import register_app_access_error_metadata from controllers.web import site as site_module +from controllers.web.error import WebAppNotFoundError +from extensions.ext_application_services import ApplicationServices +from libs.external_api import ExternalApi +from models.model import App, EndUser from services.app_definition_query_service import AppSiteConfiguration from services.web_app_runtime_query_service import ( WebAppBootstrap, WebAppRuntimeUnavailableError, ) +from tests.unit_tests.config_override import apply_config_overrides def _bootstrap() -> WebAppBootstrap: @@ -70,7 +78,7 @@ def test_app_site_api_queries_the_admitted_app_runtime() -> None: web_app_runtime.get_bootstrap.assert_called_once_with("app-id") -def test_app_site_api_maps_unavailable_runtime_to_forbidden() -> None: +def test_app_site_api_maps_unavailable_runtime_to_app_not_found() -> None: web_app_runtime = MagicMock() web_app_runtime.get_bootstrap.side_effect = WebAppRuntimeUnavailableError @@ -80,6 +88,56 @@ def test_app_site_api_maps_unavailable_runtime_to_forbidden() -> None: "application_services", return_value=SimpleNamespace(web_app_runtime=web_app_runtime), ), - pytest.raises(Forbidden), + pytest.raises(WebAppNotFoundError), ): site_module.AppSiteApi().get(MagicMock(id="app-id"), MagicMock(id="end-user-id")) + + +@pytest.mark.parametrize( + ("failure", "status"), + [ + (WebAppRuntimeUnavailableError("Site not found"), 404), + (ServiceUnavailable("dependency unavailable"), 503), + (Forbidden("business permission denied"), 403), + (RuntimeError("programming bug"), 500), + ], +) +def test_site_final_response_only_reclassifies_terminal_runtime_failure( + failure: Exception, + status: int, + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + account_application_services: ApplicationServices, +) -> None: + from controllers.web import wraps + + app = Flask(__name__) + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + bp = Blueprint("site_fixture", __name__, url_prefix="/api") + register_app_access_error_metadata(bp, surface="web") + api = ExternalApi(bp) + api.add_resource(site_module.AppSiteApi, "/site") + app.register_blueprint(bp) + mocker.patch.object(account_application_services.web_app_runtime, "get_bootstrap", side_effect=failure) + monkeypatch.setattr(site_module, "application_services", lambda: account_application_services) + monkeypatch.setattr( + wraps, + "decode_jwt_token", + lambda: ( + App(id="fixture-app"), + EndUser(id="fixture-user", tenant_id="fixture-tenant", type="browser"), + ), + ) + apply_config_overrides(monkeypatch, NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + response = app.test_client().get("/api/site", environ_overrides={"REMOTE_ADDR": "203.0.113.42"}) + assert response.status_code == status + if status == 404: + assert ( + response.data + == b'{"client_ip":"203.0.113.42","code":"app_not_found","message":"App not found.","status":404}' + ) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + else: + assert response.get_json()["code"] != "app_not_found" + assert "client_ip" not in response.get_json() diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index 26b04323dbe66b..62c1d39249861a 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -7,6 +7,9 @@ import pytest from flask import Flask, Response from flask_restx import Api +from pytest_mock import MockerFixture +from sqlalchemy import func, select, text +from sqlalchemy.orm import Session, sessionmaker from controllers.console import wraps as console_wraps from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError @@ -23,14 +26,21 @@ LoginStatusQuery, LogoutApi, ) +from extensions.ext_application_services import ApplicationServices from machinery.context import RequestContext +from models.enums import CustomizeTokenStrategy +from models.model import App, AppModelConfig, EndUser, Site from services.entities.authentication_entities import WebLoginStatus from services.web_authentication_service import ( WebAccountBannedError, WebAuthenticationFailedError, WebInvalidCodeError, ) -from services.webapp_access_query_service import WebAppAccessAppNotFoundError, WebAppAccessUnavailableError +from services.webapp_access_query_service import ( + WebAppAccessAppNotFoundError, + WebAppAccessUnavailableError, +) +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture @@ -165,6 +175,105 @@ def get_login_status(self, **kwargs: str | None) -> WebLoginStatus: assert response.get_json()["code"] == error_code +@pytest.mark.parametrize( + "condition", + ["missing-site", "dangling-site", "app-disabled", "site-disabled", "app-status-disabled", "unpublished"], +) +def test_unavailable_identity_is_canonical_404_before_authentication( + condition: str, + app: Flask, + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + account_application_services: ApplicationServices, +) -> None: + """Keep the IP-access contract across the new Web authentication service boundary.""" + from controllers.web import bp + + app_id = "11111111-1111-1111-1111-111111111111" + config_id = "22222222-2222-2222-2222-222222222222" + with sqlite_session_factory.begin() as session: + if condition != "dangling-site": + config = AppModelConfig(app_id=app_id) + config.id = config_id + session.add(config) + session.add( + App( + id=app_id, + tenant_id=app_id, + name="Private fixture", + mode="chat", + enable_site=condition != "app-disabled", + enable_api=True, + app_model_config_id=config_id if condition != "unpublished" else None, + ) + ) + if condition != "missing-site": + session.add( + Site( + app_id=app_id, + code="private-fixture", + title="Private fixture", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + ) + ) + session.flush() + if condition == "site-disabled": + session.execute(text("UPDATE sites SET status='disabled' WHERE app_id=:app_id"), {"app_id": app_id}) + elif condition == "app-status-disabled": + session.execute(text("UPDATE apps SET status='disabled' WHERE id=:app_id"), {"app_id": app_id}) + + access = account_application_services.webapp_access + service = account_application_services.web_authentication + mode_lookup = mocker.spy(access._policy, "get_access_mode") + token_verify = mocker.spy(service._tokens, "verify_access_token") + session_verify = mocker.spy(service._app_sessions, "verify") + bind_service(monkeypatch, service) + monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True) + apply_config_overrides(monkeypatch, NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + app.register_blueprint(bp) + response = app.test_client().get( + "/api/login/status?app_code=private-fixture", environ_overrides={"REMOTE_ADDR": "203.0.113.42"} + ) + + assert response.status_code == 404 + assert response.data == ( + b'{"client_ip":"203.0.113.42","code":"app_not_found","message":"App not found.","status":404}' + ) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + mode_lookup.assert_not_called() + token_verify.assert_not_called() + session_verify.assert_not_called() + with sqlite_session_factory() as session: + assert session.scalar(select(func.count()).select_from(EndUser)) == 0 + + +@pytest.mark.parametrize( + ("failure", "status"), + [(WebAppAccessUnavailableError("private dependency"), 503), (TypeError("private bug"), 500)], +) +def test_login_status_dependency_or_bug_is_not_hidden_as_app_404( + failure: Exception, status: int, app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + from controllers.web import bp + + class FailingLoginStatusService(LoginStatusStub): + @override + def get_login_status(self, **kwargs: str | None) -> WebLoginStatus: + raise failure + + bind_service(monkeypatch, FailingLoginStatusService()) + monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True) + app.register_blueprint(bp) + response = app.test_client().get("/api/login/status?app_code=fixture") + + assert response.status_code == status + assert "client_ip" not in response.get_json() + assert response.get_json()["code"] != "app_not_found" + + class EmailLoginStub: def send_email_login_code(self, *, email: str, language: str | None) -> str: assert (email, language) == ("User@Example.com", "zh-Hans") diff --git a/api/tests/unit_tests/controllers/web/test_wraps.py b/api/tests/unit_tests/controllers/web/test_wraps.py index 638eeadc3b1816..353b9514d42342 100644 --- a/api/tests/unit_tests/controllers/web/test_wraps.py +++ b/api/tests/unit_tests/controllers/web/test_wraps.py @@ -1,13 +1,16 @@ +from typing import Never from unittest import mock from uuid import uuid4 import pytest -from flask import Flask +from flask import Blueprint, Flask from sqlalchemy.orm import Session -from werkzeug.exceptions import Unauthorized +from werkzeug.exceptions import NotFound, Unauthorized from core.logging.context import clear_request_context, get_identity_context from models import App, EndUser +from models.model import AppModelConfig +from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) @@ -67,6 +70,11 @@ def test_decode_jwt_token_uses_shared_session_factory(sqlite_session: Session) - enable_api=True, ) sqlite_session.add(app_model) + sqlite_session.flush() + config = AppModelConfig(app_id=app_model.id) + config.id = str(uuid4()) + app_model.app_model_config_id = config.id + sqlite_session.add(config) sqlite_session.commit() site = Site( @@ -107,6 +115,104 @@ def test_decode_jwt_token_uses_shared_session_factory(sqlite_session: Session) - assert result_end_user.id == end_user.id +@pytest.mark.parametrize("missing", ["app", "site", "code", "disabled", "unpublished", "end_user", "resource"]) +def test_post_missing_app_is_canonical_but_other_not_found_keeps_its_owner( + sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, missing: str +) -> None: + from controllers.common.app_access_error import register_app_access_error_metadata + from controllers.web import wraps + from libs.external_api import ExternalApi + from models.enums import EndUserType + from models.model import AppMode, CustomizeTokenStrategy, Site + + app_id, tenant_id, end_user_id = str(uuid4()), str(uuid4()), str(uuid4()) + config_id = str(uuid4()) + if missing != "app": + sqlite_session.add( + App( + id=app_id, + tenant_id=tenant_id, + name="fixture", + mode=AppMode.CHAT, + enable_site=missing != "disabled", + enable_api=True, + app_model_config_id=config_id if missing != "unpublished" else None, + ) + ) + if missing != "unpublished": + config = AppModelConfig(app_id=app_id) + config.id = config_id + sqlite_session.add(config) + if missing != "site": + sqlite_session.add( + Site( + app_id=app_id, + code="fixture-code", + title="fixture", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + ) + ) + if missing == "resource": + sqlite_session.add( + EndUser( + id=end_user_id, + tenant_id=tenant_id, + app_id=app_id, + type=EndUserType.BROWSER, + session_id="fixture-session", + ) + ) + sqlite_session.commit() + apply_config_overrides(monkeypatch, NETWORK_ACCESS_TRUSTED_PROXY_CIDRS="172.18.0.0/16") + app = Flask(__name__) + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + bp = Blueprint("web_post_fixture", __name__, url_prefix="/api") + register_app_access_error_metadata(bp, surface="web") + api = ExternalApi(bp) + calls: list[str] = [] + + class ProtectedResource(wraps.WebApiResource): + def post(self, _app: App, _end_user: EndUser) -> Never: + calls.append("view") + raise NotFound("Conversation not found.") + + api.add_resource(ProtectedResource, "/chat-messages") + app.register_blueprint(bp) + with ( + mock.patch.object(wraps, "extract_webapp_passport", return_value="fixture-passport"), + mock.patch.object(wraps, "PassportService") as passport, + mock.patch.object(wraps.SystemFeatureService, "is_webapp_auth_enabled", return_value=False), + ): + passport.return_value.verify.return_value = { + "app_code": None if missing == "code" else "fixture-code", + "app_id": app_id, + "end_user_id": end_user_id, + } + response = app.test_client().post( + "/api/chat-messages", + json={}, + headers={"X-App-Code": "fixture-code"}, + environ_overrides={"REMOTE_ADDR": "203.0.113.42"}, + ) + + assert response.status_code == 404 + if missing in {"app", "site", "code", "disabled", "unpublished"}: + assert ( + response.data + == b'{"client_ip":"203.0.113.42","code":"app_not_found","message":"App not found.","status":404}' + ) + assert response.headers["Content-Type"] == "application/json" + assert response.headers["Cache-Control"] == "no-store" + else: + assert response.get_json()["code"] == "not_found" + assert "client_ip" not in response.get_json() + assert "Cache-Control" not in response.headers + if missing == "resource": + assert response.get_json()["message"] == "Conversation not found." + assert calls == (["view"] if missing == "resource" else []) + + @pytest.mark.usefixtures("app_query_services") @pytest.mark.parametrize("token", [None, "expired-token"]) def test_web_auth_rejects_unknown_app_code_with_http_not_found(app, monkeypatch, token): diff --git a/api/tests/unit_tests/core/network_access/test_client_ip.py b/api/tests/unit_tests/core/network_access/test_client_ip.py new file mode 100644 index 00000000000000..b876f5b0a82de4 --- /dev/null +++ b/api/tests/unit_tests/core/network_access/test_client_ip.py @@ -0,0 +1,108 @@ +import pytest + +from core.network_access.client_ip import NetworkAccessClientIPUnavailableError, resolve_network_access_client_ip + +TRUSTED = "172.18.0.0/16,10.0.0.0/8,2400:cb00::/32" + + +@pytest.mark.parametrize("config", ["", TRUSTED]) +def test_untrusted_socket_ignores_all_forwarding_headers(config: str) -> None: + assert ( + resolve_network_access_client_ip( + { + "REMOTE_ADDR": "203.0.113.42", + "HTTP_X_FORWARDED_FOR": "192.0.2.1, invalid", + "HTTP_CF_CONNECTING_IP": "192.0.2.1", + "HTTP_X_REAL_IP": "192.0.2.1", + }, + config, + ) + == "203.0.113.42" + ) + + +@pytest.mark.parametrize( + ("header", "expected"), + [ + ("203.0.113.42", "203.0.113.42"), + ("192.0.2.1, 203.0.113.42, 10.2.3.4", "203.0.113.42"), + ("2001:db8::1234, 2400:cb00::1, 10.2.3.4", "2001:db8::1234"), + ("::ffff:203.0.113.42, ::ffff:10.2.3.4", "203.0.113.42"), + (" 2001:0db8:0:0::1234 , 10.2.3.4 ", "2001:db8::1234"), + ], +) +def test_trusted_chain_strips_only_rightmost_trusted_hops(header: str, expected: str) -> None: + assert ( + resolve_network_access_client_ip({"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": header}, TRUSTED) + == expected + ) + + +@pytest.mark.parametrize( + "header", + [ + None, + "", + "203.0.113.42,", + ",203.0.113.42", + "unknown", + "203.0.113.42:443", + "[2001:db8::1]", + "fe80::1%eth0", + "192.0.2.1,invalid", + "10.1.2.3", + "192.0.2.1," * 33, + "x" * 2049, + ], +) +def test_trusted_peer_rejects_missing_invalid_or_all_trusted_chains(header: object) -> None: + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": "172.18.0.2", "HTTP_X_FORWARDED_FOR": header}, TRUSTED) + + +@pytest.mark.parametrize("peer", [None, "", "unknown", "[::1]", "203.0.113.42:5000", "fe80::1%eth0", 1]) +def test_invalid_socket_peer_is_unavailable(peer: object) -> None: + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": peer}, TRUSTED) + + +def test_proxy_fix_cannot_make_untrusted_origin_appear_trusted() -> None: + environ = { + "REMOTE_ADDR": "172.18.0.2", + "werkzeug.proxy_fix.orig": {"REMOTE_ADDR": "203.0.113.42"}, + "HTTP_X_FORWARDED_FOR": "192.0.2.1", + } + assert resolve_network_access_client_ip(environ, TRUSTED) == "203.0.113.42" + + +def test_proxy_fix_preserves_trusted_socket_even_after_remote_addr_rewrite() -> None: + environ = { + "REMOTE_ADDR": "192.0.2.1", + "werkzeug.proxy_fix.orig": {"REMOTE_ADDR": "172.18.0.2"}, + "HTTP_X_FORWARDED_FOR": "192.0.2.1, 203.0.113.42", + } + assert resolve_network_access_client_ip(environ, TRUSTED) == "203.0.113.42" + + +@pytest.mark.parametrize("original", ["invalid", {}, {"REMOTE_ADDR": None}]) +def test_malformed_proxy_fix_original_is_unavailable(original: object) -> None: + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": "203.0.113.42", "werkzeug.proxy_fix.orig": original}, TRUSTED) + + +@pytest.mark.parametrize( + "config", ["not-a-cidr", "172.18.0.1/16", "172.18.0.0/16,", "fe80::%eth0/64", "10.0.0.0/8," * 257] +) +def test_invalid_proxy_configuration_is_unavailable(config: str) -> None: + with pytest.raises(NetworkAccessClientIPUnavailableError): + resolve_network_access_client_ip({"REMOTE_ADDR": "203.0.113.42"}, config) + + +def test_mapped_socket_and_proxy_cidr_match_ipv4() -> None: + assert ( + resolve_network_access_client_ip( + {"REMOTE_ADDR": "::ffff:172.18.0.2", "HTTP_X_FORWARDED_FOR": "::ffff:203.0.113.42"}, + "::ffff:172.18.0.0/112", + ) + == "203.0.113.42" + ) diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index 784fa01c36f5af..32de236ce2290d 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -28,7 +28,7 @@ from extensions.ext_redis import RedisClientWrapper from machinery.context import RequestContext from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole -from models.enums import CustomizeTokenStrategy +from models.enums import AppStatus, CustomizeTokenStrategy from models.model import ( AccountTrialAppRecord, App, @@ -54,6 +54,7 @@ from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository from repositories.upload_file_delivery_repository import UploadFileDeliveryQueryRepository @@ -138,6 +139,11 @@ from services.knowledge.indexing.estimate import IndexingEstimateApplicationService from services.knowledge.segments.application import DatasetSegmentApplicationService from services.message_file_preview_service import MessageFilePreviewService +from services.network_access_group_gateway import ( + BillingNetworkAccessGroupEntitlementGateway, + NetworkAccessGroupGateway, +) +from services.network_access_group_service import NetworkAccessGroupAccessDeniedError, NetworkAccessGroupService from services.oauth_device_application_service import OAuthDeviceApplicationService from services.plugin_file_upload_gateway import ToolFilePluginUploadGateway from services.plugin_file_upload_service import PluginFileUploadService @@ -699,6 +705,47 @@ def test_build_application_services_wires_billing_service( get_invoices.assert_called_once_with("owner@example.com", "workspace-1") +def test_build_application_services_wires_network_access_group_boundary( + sqlite_session_factory: sessionmaker[Session], +) -> None: + services = ext_application_services.build_application_services( + database_client=sqlite_session_factory, + deployment_edition=DeploymentEdition.CLOUD, + initialization_password="", + redis=_redis(), + ) + + network_access_groups = services.network_access_groups + assert isinstance(network_access_groups, NetworkAccessGroupService) + assert isinstance(network_access_groups._control_plane, NetworkAccessGroupGateway) + assert isinstance(network_access_groups._apps, SQLAlchemyNetworkAccessGroupAppRepository) + assert network_access_groups._apps._session_factory is sqlite_session_factory + assert isinstance(network_access_groups._entitlement, BillingNetworkAccessGroupEntitlementGateway) + + # Exercise the composed membership port, not only the class of its owner. + # A workspace-repository migration must not leave a stale method signature. + context = RequestContext("request-1", None, "account-1", "workspace-1", "203.0.113.42") + with pytest.raises(NetworkAccessGroupAccessDeniedError): + network_access_groups.get_current_ip(context, client_ip_supplier=lambda: "203.0.113.42") + with sqlite_session_factory.begin() as session: + account = Account(name="QA owner", email="qa-owner@example.com") + account.id = context.account_id + workspace = Tenant(name="QA workspace") + workspace.id = context.active_workspace_id + session.add_all( + [ + account, + workspace, + TenantAccountJoin( + tenant_id=workspace.id, + account_id=account.id, + role=TenantAccountRole.OWNER, + ), + ] + ) + assert network_access_groups.get_current_ip(context, client_ip_supplier=lambda: "203.0.113.42") == "203.0.113.42" + + def test_build_application_services_wires_compliance_downloads( sqlite_session_factory: sessionmaker[Session], ) -> None: @@ -1017,6 +1064,35 @@ def test_build_application_services_wires_trial_app_usage( assert record.count == 1 +@pytest.fixture +def published_webapp_id(sqlite_session: Session) -> str: + """Keep public admission real so wiring tests reach the Enterprise adapter.""" + app_id = str(uuid4()) + config = AppModelConfig(app_id=app_id) + config.id = str(uuid4()) + app = App( + id=app_id, + tenant_id=str(uuid4()), + name="Published adapter fixture", + mode=AppMode.CHAT, + status=AppStatus.NORMAL, + enable_site=True, + enable_api=True, + app_model_config_id=config.id, + ) + site = Site( + app_id=app_id, + code="published-adapter-fixture", + title="Published adapter fixture", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + status=AppStatus.NORMAL, + ) + sqlite_session.add_all([app, config, site]) + sqlite_session.commit() + return app_id + + @pytest.fixture def installed_app_ref(sqlite_session_factory: sessionmaker[Session]) -> InstalledAppRef: with sqlite_session_factory.begin() as session: @@ -1306,6 +1382,7 @@ def test_app_preview_details_use_the_configured_database_without_request_globals def test_build_application_services_adapts_enterprise_webapp_access_mode( sqlite_session_factory: sessionmaker[Session], + published_webapp_id: str, ) -> None: with ( patch("extensions.ext_application_services.SystemFeatureService.is_webapp_auth_enabled", return_value=True), @@ -1320,17 +1397,17 @@ def test_build_application_services_adapts_enterprise_webapp_access_mode( initialization_password="", redis=_redis(), ) - result = services.webapp_access.get_access_mode(app_id="app-1", app_code=None) + result = services.webapp_access.get_access_mode(app_id=published_webapp_id, app_code=None) assert result is WebAppAccessMode.PRIVATE_ALL - get_access_mode.assert_called_once_with("app-1") + get_access_mode.assert_called_once_with(published_webapp_id) def _query_webapp_access( - service: WebAppAccessQueryService, query_kind: str + service: WebAppAccessQueryService, query_kind: str, published_webapp_id: str ) -> WebAppAccessMode | bool | Mapping[str, WebAppAccessMode] | Mapping[str, bool]: if query_kind == "single-mode": - return service.get_access_mode(app_id="app-1", app_code=None) + return service.get_access_mode(app_id=published_webapp_id, app_code=None) if query_kind == "single-permission": return service.is_user_allowed(user_id="viewer", app_id="app-1") if query_kind == "batch-modes": @@ -1352,6 +1429,7 @@ def _query_webapp_access( ) def test_webapp_access_queries_map_known_enterprise_errors_to_unavailable( sqlite_session_factory: sessionmaker[Session], + published_webapp_id: str, query_kind: str, enterprise_error: Exception, ) -> None: @@ -1365,7 +1443,7 @@ def test_webapp_access_queries_map_known_enterprise_errors_to_unavailable( redis=_redis(), ) with pytest.raises(WebAppAccessUnavailableError) as raised: - _query_webapp_access(services.webapp_access, query_kind) + _query_webapp_access(services.webapp_access, query_kind, published_webapp_id) assert type(raised.value) is WebAppAccessUnavailableError assert raised.value.__cause__ is enterprise_error @@ -1374,7 +1452,7 @@ def test_webapp_access_queries_map_known_enterprise_errors_to_unavailable( @pytest.mark.parametrize("access_mode", ["invalid", 123]) def test_single_webapp_mode_maps_invalid_enum_or_field_value_to_unavailable( - sqlite_session_factory: sessionmaker[Session], access_mode: str | int + sqlite_session_factory: sessionmaker[Session], published_webapp_id: str, access_mode: str | int ) -> None: with patch( "services.enterprise.enterprise_service.EnterpriseRequest.send_request", @@ -1387,7 +1465,7 @@ def test_single_webapp_mode_maps_invalid_enum_or_field_value_to_unavailable( redis=_redis(), ) with pytest.raises(WebAppAccessUnavailableError) as raised: - services.webapp_access.get_access_mode(app_id="app-1", app_code=None) + services.webapp_access.get_access_mode(app_id=published_webapp_id, app_code=None) assert type(raised.value) is WebAppAccessUnavailableError assert isinstance(raised.value.__cause__, ValueError) @@ -1396,7 +1474,7 @@ def test_single_webapp_mode_maps_invalid_enum_or_field_value_to_unavailable( @pytest.mark.parametrize("query_kind", ["single-mode", "single-permission", "batch-modes", "batch-permissions"]) @pytest.mark.parametrize("failure", [TypeError("adapter bug"), ValueError("unexpected programming error")]) def test_webapp_access_queries_do_not_hide_unknown_programming_errors( - sqlite_session_factory: sessionmaker[Session], query_kind: str, failure: Exception + sqlite_session_factory: sessionmaker[Session], published_webapp_id: str, query_kind: str, failure: Exception ) -> None: with patch("services.enterprise.enterprise_service.EnterpriseRequest.send_request", side_effect=failure): services = ext_application_services.build_application_services( @@ -1406,7 +1484,7 @@ def test_webapp_access_queries_do_not_hide_unknown_programming_errors( redis=_redis(), ) with pytest.raises(type(failure)) as raised: - _query_webapp_access(services.webapp_access, query_kind) + _query_webapp_access(services.webapp_access, query_kind, published_webapp_id) assert raised.value is failure diff --git a/api/tests/unit_tests/repositories/test_network_access_group_repository.py b/api/tests/unit_tests/repositories/test_network_access_group_repository.py new file mode 100644 index 00000000000000..11d82d88d51592 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_network_access_group_repository.py @@ -0,0 +1,247 @@ +from unittest.mock import patch + +import pytest +from sqlalchemy import event, text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +from models.agent import Agent, AgentScope, AgentSource, AgentStatus +from models.model import App, AppMode, IconType +from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository +from services.network_access_group_service import NetworkAccessGroupAppQueryError, NetworkAccessGroupAppRecord + +_WORKSPACE_ID = "11111111-1111-1111-1111-111111111111" +_OTHER_WORKSPACE_ID = "22222222-2222-2222-2222-222222222222" +_APP_ID = "33333333-3333-3333-3333-333333333333" +_OTHER_APP_ID = "44444444-4444-4444-4444-444444444444" +_DISABLED_APP_ID = "55555555-5555-5555-5555-555555555555" +_UNREQUESTED_APP_ID = "66666666-6666-6666-6666-666666666666" + + +def _app( + app_id: str, + *, + workspace_id: str = _WORKSPACE_ID, + mode: AppMode = AppMode.WORKFLOW, +) -> App: + return App( + id=app_id, + tenant_id=workspace_id, + name=f"App {app_id}", + description="", + mode=mode, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#ffffff", + enable_site=True, + enable_api=True, + ) + + +def _repository(session_factory: sessionmaker[Session]) -> SQLAlchemyNetworkAccessGroupAppRepository: + return SQLAlchemyNetworkAccessGroupAppRepository(session_factory=session_factory) + + +def test_get_manageable_app_returns_detached_projection_and_enforces_workspace_and_status_scope( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + session.add_all( + [ + _app(_APP_ID), + _app(_OTHER_APP_ID, workspace_id=_OTHER_WORKSPACE_ID), + _app(_DISABLED_APP_ID), + ] + ) + session.commit() + session.execute( + text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), + {"app_id": _DISABLED_APP_ID}, + ) + session.commit() + + repository = _repository(sqlite_session_factory) + + result = repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_APP_ID) + + assert result == NetworkAccessGroupAppRecord( + id=_APP_ID, + mode=AppMode.WORKFLOW.value, + name=f"App {_APP_ID}", + icon="robot", + icon_type=IconType.EMOJI.value, + icon_background="#ffffff", + ) + assert repository.get_manageable_app(workspace_id=_OTHER_WORKSPACE_ID, app_id=_APP_ID) is None + assert repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_OTHER_APP_ID) is None + assert repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_DISABLED_APP_ID) is None + assert repository.get_manageable_app(workspace_id=_WORKSPACE_ID, app_id=_UNREQUESTED_APP_ID) is None + + +def test_list_apps_returns_only_normal_apps_in_the_requested_workspace( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + session.add_all( + [ + _app(_APP_ID), + _app(_OTHER_APP_ID, workspace_id=_OTHER_WORKSPACE_ID), + _app(_DISABLED_APP_ID), + _app(_UNREQUESTED_APP_ID), + ] + ) + session.commit() + session.execute( + text("UPDATE apps SET status = 'disabled' WHERE id = :app_id"), + {"app_id": _DISABLED_APP_ID}, + ) + session.commit() + + result = _repository(sqlite_session_factory).list_apps( + workspace_id=_WORKSPACE_ID, + app_ids=[_APP_ID, _OTHER_APP_ID, _DISABLED_APP_ID], + ) + + assert result == ( + NetworkAccessGroupAppRecord( + id=_APP_ID, + mode=AppMode.WORKFLOW.value, + name=f"App {_APP_ID}", + icon="robot", + icon_type=IconType.EMOJI.value, + icon_background="#ffffff", + ), + ) + + +@pytest.mark.parametrize( + ("scope", "source", "status", "expected_visible"), + [ + (AgentScope.WORKFLOW_ONLY, AgentSource.WORKFLOW, AgentStatus.ACTIVE, False), + (AgentScope.WORKFLOW_ONLY, AgentSource.WORKFLOW, AgentStatus.ARCHIVED, False), + (AgentScope.ROSTER, AgentSource.AGENT_APP, AgentStatus.ACTIVE, True), + ], +) +def test_get_manageable_app_applies_agent_scope_and_lifecycle_visibility( + sqlite_session_factory: sessionmaker[Session], + scope: AgentScope, + source: AgentSource, + status: AgentStatus, + expected_visible: bool, +) -> None: + app = _app(_APP_ID, mode=AppMode.AGENT) + with sqlite_session_factory() as session: + session.add_all( + [ + app, + Agent( + id="77777777-7777-7777-7777-777777777777", + tenant_id=_WORKSPACE_ID, + name="Bound agent", + description="", + role="", + scope=scope, + source=source, + status=status, + app_id=app.id if scope == AgentScope.ROSTER else "88888888-8888-8888-8888-888888888888", + backing_app_id=app.id if scope == AgentScope.WORKFLOW_ONLY else None, + workflow_id=("99999999-9999-9999-9999-999999999999" if scope == AgentScope.WORKFLOW_ONLY else None), + workflow_node_id="agent-node" if scope == AgentScope.WORKFLOW_ONLY else None, + ), + ] + ) + session.commit() + + result = _repository(sqlite_session_factory).get_manageable_app( + workspace_id=_WORKSPACE_ID, + app_id=_APP_ID, + ) + + assert (result is not None) is expected_visible + if result is not None: + assert result.bound_agent_id == "77777777-7777-7777-7777-777777777777" + + +def test_list_apps_batch_loads_agent_routes_and_excludes_hidden_workflow_backing_apps( + sqlite_session_factory: sessionmaker[Session], +) -> None: + agent_app_id = _APP_ID + second_agent_app_id = _UNREQUESTED_APP_ID + hidden_app_id = _DISABLED_APP_ID + with sqlite_session_factory() as session: + session.add_all( + [ + _app(agent_app_id, mode=AppMode.AGENT), + _app(second_agent_app_id, mode=AppMode.AGENT), + _app(hidden_app_id, mode=AppMode.AGENT), + Agent( + id="77777777-7777-7777-7777-777777777777", + tenant_id=_WORKSPACE_ID, + name="First roster agent", + description="", + role="", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id=agent_app_id, + backing_app_id=agent_app_id, + ), + Agent( + id="88888888-8888-8888-8888-888888888888", + tenant_id=_WORKSPACE_ID, + name="Second roster agent", + description="", + role="", + scope=AgentScope.ROSTER, + source=AgentSource.IMPORTED, + status=AgentStatus.ACTIVE, + app_id=second_agent_app_id, + backing_app_id=second_agent_app_id, + ), + Agent( + id="99999999-9999-9999-9999-999999999999", + tenant_id=_WORKSPACE_ID, + name="Workflow-only agent", + description="", + role="", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + app_id=_OTHER_APP_ID, + backing_app_id=hidden_app_id, + workflow_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + workflow_node_id="agent-node", + ), + ] + ) + session.commit() + engine = session.get_bind() + + agent_selects: list[str] = [] + + def record_agent_select(_conn, _cursor, statement: str, _parameters, _context, _executemany) -> None: + if "FROM agents" in statement: + agent_selects.append(statement) + + event.listen(engine, "before_cursor_execute", record_agent_select) + try: + result = _repository(sqlite_session_factory).list_apps( + workspace_id=_WORKSPACE_ID, + app_ids=[agent_app_id, second_agent_app_id, hidden_app_id], + ) + finally: + event.remove(engine, "before_cursor_execute", record_agent_select) + + assert [(app.id, app.bound_agent_id) for app in result] == [ + (agent_app_id, "77777777-7777-7777-7777-777777777777"), + (second_agent_app_id, "88888888-8888-8888-8888-888888888888"), + ] + assert len(agent_selects) == 1 + + +def test_list_apps_maps_database_failures_to_query_error(sqlite_session_factory: sessionmaker[Session]) -> None: + with ( + patch.object(Session, "scalars", side_effect=SQLAlchemyError("database unavailable")), + pytest.raises(NetworkAccessGroupAppQueryError), + ): + _repository(sqlite_session_factory).list_apps(workspace_id=_WORKSPACE_ID, app_ids=[_APP_ID]) diff --git a/api/tests/unit_tests/repositories/test_public_app_availability.py b/api/tests/unit_tests/repositories/test_public_app_availability.py new file mode 100644 index 00000000000000..5c654da4d262c5 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_public_app_availability.py @@ -0,0 +1,202 @@ +"""Exercise the shared public publication gate using real persisted owner chains.""" + +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from core.app.public_runtime import published_app_filter +from models.agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, +) +from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, Site +from models.workflow import Workflow, WorkflowType +from repositories.web_passport_repository import WebPassportRepository +from repositories.webapp_access_query_repository import WebAppAccessQueryRepository + + +def _app(session: Session, mode: AppMode) -> App: + app = App( + id=str(uuid4()), tenant_id=str(uuid4()), name="Private fixture", mode=mode, enable_site=True, enable_api=True + ) + session.add_all( + [ + app, + Site( + app_id=app.id, + code="public-code", + title="private", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + ), + ] + ) + session.flush() + return app + + +def _workflow(session: Session, app: App, *, version: str = "published") -> Workflow: + workflow = Workflow( + id=str(uuid4()), + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW if app.mode == AppMode.WORKFLOW else WorkflowType.CHAT, + version=version, + graph='{"nodes":[]}', + features="{}", + created_by=str(uuid4()), + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + session.add(workflow) + return workflow + + +def _agent(session: Session, app: App, *, published: bool) -> tuple[Agent, AgentConfigSnapshot]: + agent = Agent( + id=str(uuid4()), + tenant_id=app.tenant_id, + app_id=app.id, + name="Private Agent", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + active_config_is_published=published, + ) + snapshot = AgentConfigSnapshot( + id=str(uuid4()), + tenant_id=app.tenant_id, + agent_id=agent.id, + version=1, + home_snapshot_id=str(uuid4()), + config_snapshot={}, + ) + agent.active_config_snapshot_id = snapshot.id + session.add_all([agent, snapshot]) + if published: + session.add( + AgentConfigRevision( + tenant_id=app.tenant_id, + agent_id=agent.id, + current_snapshot_id=snapshot.id, + revision=1, + operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, + ) + ) + return agent, snapshot + + +def _assert_readers(factory: sessionmaker[Session], app: App, expected: bool) -> None: + access = WebAppAccessQueryRepository(session_factory=factory) + assert access.find_app_id_by_code("public-code") == (app.id if expected else None) + assert access.is_app_available(app.id) is expected + passports = WebPassportRepository(session_factory=factory, generate_session_id=lambda: "never-created") + record = passports.get_active_web_app("public-code") + assert (record is not None) is expected + + +@pytest.mark.parametrize( + "mode", + [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT, AppMode.CHAT, AppMode.COMPLETION, AppMode.AGENT_CHAT, AppMode.AGENT], +) +@pytest.mark.parametrize("published", [False, True]) +def test_all_public_app_modes_require_their_published_artifact( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], mode: AppMode, published: bool +) -> None: + app = _app(sqlite_session, mode) + if mode == AppMode.AGENT: + _agent(sqlite_session, app, published=published) + elif mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT): + draft = _workflow(sqlite_session, app, version="draft") + if published: + app.workflow_id = _workflow(sqlite_session, app).id + assert draft.id != app.workflow_id + elif published: + config = AppModelConfig(app_id=app.id) + config.id = str(uuid4()) + app.app_model_config_id = config.id + sqlite_session.add(config) + sqlite_session.commit() + _assert_readers(sqlite_session_factory, app, published) + + +@pytest.mark.parametrize("invalid", ["draft-pointer", "missing", "other-app", "other-tenant"]) +@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) +def test_workflow_pointer_must_resolve_to_this_apps_published_workflow( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], mode: AppMode, invalid: str +) -> None: + app = _app(sqlite_session, mode) + workflow = _workflow(sqlite_session, app, version="draft" if invalid == "draft-pointer" else "published") + app.workflow_id = str(uuid4()) if invalid == "missing" else workflow.id + if invalid == "other-app": + workflow.app_id = str(uuid4()) + if invalid == "other-tenant": + workflow.tenant_id = str(uuid4()) + sqlite_session.commit() + _assert_readers(sqlite_session_factory, app, False) + + +@pytest.mark.parametrize("invalid", ["missing", "other-app"]) +def test_model_config_pointer_must_resolve_to_this_app( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], invalid: str +) -> None: + app = _app(sqlite_session, AppMode.CHAT) + config = AppModelConfig(app_id=app.id if invalid == "missing" else str(uuid4())) + config.id = str(uuid4()) + app.app_model_config_id = str(uuid4()) if invalid == "missing" else config.id + sqlite_session.add(config) + sqlite_session.commit() + _assert_readers(sqlite_session_factory, app, False) + + +@pytest.mark.parametrize( + "state", + [ + "dirty-draft", + "seeded", + "snapshot-missing", + "snapshot-other-agent", + "snapshot-other-tenant", + "agent-other-tenant", + "archived", + "workflow-only", + ], +) +def test_agent_publication_uses_owned_visible_snapshot_not_dirty_draft_flag( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], state: str +) -> None: + app = _app(sqlite_session, AppMode.AGENT) + agent, snapshot = _agent(sqlite_session, app, published=state != "seeded") + # A seeded snapshot is not public even when an inconsistent dirty flag says otherwise. + agent.active_config_is_published = state == "seeded" + if state == "snapshot-missing": + agent.active_config_snapshot_id = str(uuid4()) + elif state == "snapshot-other-agent": + snapshot.agent_id = str(uuid4()) + elif state == "snapshot-other-tenant": + snapshot.tenant_id = str(uuid4()) + elif state == "agent-other-tenant": + agent.tenant_id = str(uuid4()) + elif state == "archived": + agent.status = AgentStatus.ARCHIVED + elif state == "workflow-only": + agent.scope = AgentScope.WORKFLOW_ONLY + agent.backing_app_id = app.id + sqlite_session.commit() + _assert_readers(sqlite_session_factory, app, state == "dirty-draft") + + +def test_public_predicate_does_not_change_management_visibility(sqlite_session: Session) -> None: + app = _app(sqlite_session, AppMode.ADVANCED_CHAT) + draft = _workflow(sqlite_session, app, version="draft") + sqlite_session.commit() + assert sqlite_session.scalar(select(App.id).where(App.id == app.id, published_app_filter())) is None + assert sqlite_session.get(App, app.id) is app + assert sqlite_session.get(Workflow, draft.id) is draft diff --git a/api/tests/unit_tests/repositories/test_web_passport_repository.py b/api/tests/unit_tests/repositories/test_web_passport_repository.py index 6c5a66d5528acc..4fcc787e0f29b7 100644 --- a/api/tests/unit_tests/repositories/test_web_passport_repository.py +++ b/api/tests/unit_tests/repositories/test_web_passport_repository.py @@ -2,11 +2,12 @@ from unittest.mock import MagicMock from uuid import NAMESPACE_URL, uuid5 +import pytest from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from models.enums import CustomizeTokenStrategy, EndUserType -from models.model import App, AppMode, EndUser, IconType, Site +from models.model import App, AppMode, AppModelConfig, EndUser, IconType, Site from repositories.web_passport_repository import WebPassportRepository @@ -20,6 +21,7 @@ def _persist_webapp( app_code: str = "code", identity: str | None = None, enable_site: bool = True, + published: bool = True, ) -> tuple[App, Site]: identity = identity or app_code app = App( @@ -42,6 +44,11 @@ def _persist_webapp( code=app_code, ) session.add_all([app, site]) + if published: + config = AppModelConfig(app_id=app.id) + config.id = _stable_uuid(f"config:{identity}") + app.app_model_config_id = config.id + session.add(config) session.commit() return app, site @@ -85,6 +92,34 @@ def test_get_active_web_app_rejects_disabled_app( assert repository.get_active_web_app("code") is None +def test_unpublished_app_does_not_resolve_for_passport( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] +) -> None: + _persist_webapp(sqlite_session, published=False) + assert _repository(sqlite_session_factory).get_active_web_app("code") is None + + +@pytest.mark.parametrize("authenticated", [False, True]) +def test_publication_is_rechecked_before_end_user_write( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], authenticated: bool +) -> None: + app, _ = _persist_webapp(sqlite_session) + repository = _repository(sqlite_session_factory) + record = repository.get_active_web_app("code") + assert record is not None + app.app_model_config_id = None + sqlite_session.commit() + + if authenticated: + resolution = repository.resolve_authenticated_end_user(record, session_id="not-created", end_user_id=None) + else: + resolution = repository.resolve_standard_end_user(record, "not-created") + + assert resolution.app_active is False + assert resolution.end_user is None + assert sqlite_session.scalar(select(EndUser).where(EndUser.app_id == app.id)) is None + + def test_get_active_web_app_accepts_duplicate_site_codes( sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], diff --git a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py index 1f405ef811dddd..dc3adc6f325ec9 100644 --- a/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py +++ b/api/tests/unit_tests/repositories/test_webapp_access_query_repository.py @@ -1,12 +1,12 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest -from sqlalchemy import select +from sqlalchemy import Engine, event, select from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session, sessionmaker -from models.enums import CustomizeTokenStrategy, EndUserType -from models.model import App, AppMode, EndUser, Site +from models.enums import AppStatus, CustomizeTokenStrategy, EndUserType +from models.model import App, AppMode, AppModelConfig, EndUser, Site from repositories.webapp_access_query_repository import WebAppAccessQueryRepository from services.webapp_access_query_service import WebAppAccessUnavailableError @@ -55,6 +55,20 @@ def _persist_webapp_session(session: Session, *, enable_site: bool = True) -> No def test_find_app_id_by_code_returns_matching_site_app(sqlite_session_factory: sessionmaker[Session]) -> None: with sqlite_session_factory.begin() as session: + config = AppModelConfig(app_id=_APP_ID) + config.id = "22222222-2222-2222-2222-222222222222" + session.add(config) + session.add( + App( + id=_APP_ID, + tenant_id=_APP_ID, + name="Test App", + mode="chat", + enable_site=True, + enable_api=True, + app_model_config_id=config.id, + ) + ) session.add( Site( app_id=_APP_ID, @@ -68,6 +82,67 @@ def test_find_app_id_by_code_returns_matching_site_app(sqlite_session_factory: s repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) assert repository.find_app_id_by_code("site-code") == _APP_ID + assert repository.is_app_available(_APP_ID) is True + + +@pytest.mark.parametrize("disabled", ["app", "deleted-app"]) +def test_inactive_or_dangling_site_does_not_resolve_as_public_app( + sqlite_session_factory: sessionmaker[Session], disabled: str +) -> None: + with sqlite_session_factory.begin() as session: + if disabled != "deleted-app": + config = AppModelConfig(app_id=_APP_ID) + config.id = "22222222-2222-2222-2222-222222222222" + session.add(config) + session.add( + App( + id=_APP_ID, + tenant_id=_APP_ID, + name="Test App", + mode="chat", + enable_site=disabled != "app", + enable_api=True, + app_model_config_id=config.id, + ) + ) + session.add( + Site( + app_id=_APP_ID, + code="hidden", + title="Hidden", + status=AppStatus.NORMAL, + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.UUID, + ) + ) + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) + assert repository.find_app_id_by_code("hidden") is None + assert repository.is_app_available(_APP_ID) is False + + +def test_malformed_app_id_is_not_a_database_error() -> None: + factory = MagicMock() + repository = WebAppAccessQueryRepository(session_factory=factory) + assert repository.is_app_available("not-an-app-id") is False + factory.assert_not_called() + + +def test_app_id_database_failure_remains_unavailable( + sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine +) -> None: + database_error = OperationalError("select", {}, RuntimeError("connection failed")) + + def fail_query(*_args: object) -> None: + raise database_error + + event.listen(sqlite_engine, "before_cursor_execute", fail_query) + try: + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) + with pytest.raises(WebAppAccessUnavailableError) as raised: + repository.is_app_available(_APP_ID) + assert raised.value.__cause__ is database_error + finally: + event.remove(sqlite_engine, "before_cursor_execute", fail_query) def test_find_app_id_by_code_returns_none_for_missing_code( @@ -180,25 +255,26 @@ def test_find_active_session_rejects_missing_records( assert repository.find_active_session(app_id=app_id, app_code=app_code, end_user_id=end_user_id) is None -def test_find_app_id_by_code_maps_database_failures_to_unavailable() -> None: +def test_find_app_id_by_code_maps_database_failures_to_unavailable( + sqlite_session_factory: sessionmaker[Session], +) -> None: database_error = OperationalError("select", {}, RuntimeError("connection failed")) - session = MagicMock() - session.__enter__.return_value.scalar.side_effect = database_error - repository = WebAppAccessQueryRepository(session_factory=MagicMock(return_value=session)) + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) - with pytest.raises(WebAppAccessUnavailableError) as raised: + with ( + patch.object(Session, "scalar", side_effect=database_error), + pytest.raises(WebAppAccessUnavailableError) as raised, + ): repository.find_app_id_by_code("site-code") assert raised.value.__cause__ is database_error -def test_find_app_id_by_code_does_not_hide_unknown_errors() -> None: +def test_find_app_id_by_code_does_not_hide_unknown_errors(sqlite_session_factory: sessionmaker[Session]) -> None: failure = TypeError("repository bug") - session = MagicMock() - session.__enter__.return_value.scalar.side_effect = failure - repository = WebAppAccessQueryRepository(session_factory=MagicMock(return_value=session)) + repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory) - with pytest.raises(TypeError) as raised: + with patch.object(Session, "scalar", side_effect=failure), pytest.raises(TypeError) as raised: repository.find_app_id_by_code("site-code") assert raised.value is failure diff --git a/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py b/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py index 1975efe619fe6a..ff8059a3849164 100644 --- a/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py +++ b/api/tests/unit_tests/repositories/workspace/test_workspace_repository.py @@ -88,6 +88,14 @@ def test_snapshot_role_is_scoped_to_both_account_and_workspace(seeded: Workspace assert seeded.get_for_account("missing", "a1") is None +def test_membership_role_is_scoped_to_both_account_and_workspace(seeded: WorkspaceRepository) -> None: + assert seeded.get_account_role(tenant_id="w1", account_id="a1") == "normal" + assert seeded.get_account_role(tenant_id="w2", account_id="a1") == "editor" + assert seeded.get_account_role(tenant_id="w2", account_id="other") == "owner" + assert seeded.get_account_role(tenant_id="w1", account_id="other") is None + assert seeded.get_account_role(tenant_id="w2", account_id="missing") is None + + def test_switch_updates_only_the_requesting_account( seeded: WorkspaceRepository, sqlite_session_factory: sessionmaker[Session] ) -> None: diff --git a/api/tests/unit_tests/services/test_network_access_group_gateway.py b/api/tests/unit_tests/services/test_network_access_group_gateway.py new file mode 100644 index 00000000000000..2549c29da06eab --- /dev/null +++ b/api/tests/unit_tests/services/test_network_access_group_gateway.py @@ -0,0 +1,672 @@ +from collections.abc import Callable, Generator +from typing import Protocol, cast, override +from unittest.mock import call, patch + +import httpx +import pytest + +from enums import CloudPlan +from services.billing_service import _BillingHTTPStatusError +from services.network_access_group_gateway import ( + BillingNetworkAccessGroupEntitlementGateway, + NetworkAccessGroupGateway, +) +from services.network_access_group_service import ( + NetworkAccessGroupEntitlementUnavailableError, + NetworkAccessGroupInvalidResponseError, + NetworkAccessGroupUpstreamError, +) + +TENANT_ID = "11111111-1111-4111-8111-111111111111" +ACCOUNT_ID = "22222222-2222-4222-8222-222222222222" +APP_ID = "33333333-3333-4333-8333-333333333333" +GROUP_ID = "44444444-4444-4444-8444-444444444444" +HEADERS = {"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"} + + +def _group_wire(**overrides: object) -> dict[str, object]: + return { + "id": GROUP_ID, + "tenant_id": TENANT_ID, + "name": "Office", + "allowed_cidrs": ["203.0.113.7/32"], + "version": "1", + "created_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + **overrides, + } + + +def _binding_wire(**overrides: object) -> dict[str, object]: + return { + "id": GROUP_ID, + "tenant_id": TENANT_ID, + "app_id": APP_ID, + "version": "1", + "created_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + **overrides, + } + + +class RecordingHTTPClient(httpx.Client): + """Exercise httpx serialization while keeping all requests inside MockTransport.""" + + def __init__(self) -> None: + self.response = httpx.Response(200, json={}) + self.request_calls: list[object] = [] + self.wire_requests: list[httpx.Request] = [] + super().__init__( + transport=httpx.MockTransport(self._respond), + timeout=httpx.Timeout(30.0, connect=5.0), + trust_env=False, + ) + + @override + def request(self, *args: object, **kwargs: object) -> httpx.Response: + self.request_calls.append(call(*args, **kwargs)) + return cast(Callable[..., httpx.Response], super().request)(*args, **kwargs) + + def _respond(self, request: httpx.Request) -> httpx.Response: + self.wire_requests.append(request) + return httpx.Response(self.response.status_code, content=self.response.content, headers=self.response.headers) + + +class GatewayFactory(Protocol): + def __call__( + self, *, base_url: str = "https://network-access.internal/v1/" + ) -> tuple[NetworkAccessGroupGateway, RecordingHTTPClient]: ... + + +@pytest.fixture +def gateway_factory() -> Generator[GatewayFactory]: + clients: list[RecordingHTTPClient] = [] + + def create( + *, base_url: str = "https://network-access.internal/v1/" + ) -> tuple[NetworkAccessGroupGateway, RecordingHTTPClient]: + http_client = RecordingHTTPClient() + clients.append(http_client) + return ( + NetworkAccessGroupGateway( + base_url=base_url, + fallback_base_url="https://billing.internal/v1", + secret_key="test-secret", + http_client=http_client, + ), + http_client, + ) + + try: + yield create + finally: + for client in clients: + client.close() + assert client.is_closed + + +def test_network_access_requests_use_independent_api_url_when_configured(gateway_factory: GatewayFactory) -> None: + gateway, http_client = gateway_factory() + response = httpx.Response(httpx.codes.OK, json={"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()}) + http_client.response = response + + gateway.list_groups(TENANT_ID, ACCOUNT_ID) + + assert http_client.request_calls == [ + call( + "GET", + f"https://network-access.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ) + ] + + +def test_network_access_requests_fall_back_to_billing_api_url(gateway_factory: GatewayFactory) -> None: + gateway, http_client = gateway_factory(base_url="") + response = httpx.Response(httpx.codes.OK, json={"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()}) + http_client.response = response + + gateway.list_groups(TENANT_ID, ACCOUNT_ID) + + assert http_client.request_calls == [ + call( + "GET", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ) + ] + + +def test_injected_http_client_keeps_timeout_and_remains_reusable(gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + client.response = httpx.Response(200, json={"tenant_id": TENANT_ID, "entitled": True, "groups": []}) + + gateway.list_groups(TENANT_ID, ACCOUNT_ID) + + first = client.wire_requests[0] + assert first.method == "GET" + assert str(first.url) == ( + f"https://network-access.internal/v1/tenants/{TENANT_ID}/network-access-groups?actor_account_id={ACCOUNT_ID}" + ) + assert first.headers["Billing-Api-Secret-Key"] == "test-secret" + assert first.headers["Content-Type"] == "application/json" + assert first.content == b"" + assert first.extensions["timeout"] == {"connect": 5.0, "read": 30.0, "write": 30.0, "pool": 30.0} + assert not client.is_closed + + client.timeout = httpx.Timeout(8.0, connect=1.5) + gateway.list_groups(TENANT_ID, ACCOUNT_ID) + + assert len(client.wire_requests) == 2 + assert client.wire_requests[1].extensions["timeout"] == { + "connect": 1.5, + "read": 8.0, + "write": 8.0, + "pool": 8.0, + } + assert not client.is_closed + + +def test_group_list_and_item_reads_use_authenticated_saas_endpoints(gateway_factory: GatewayFactory) -> None: + gateway, http_client = gateway_factory(base_url="") + http_client.response = httpx.Response(200, json={"tenant_id": TENANT_ID, "entitled": True, "groups": []}) + list_result = gateway.list_groups(TENANT_ID, ACCOUNT_ID) + http_client.response = httpx.Response(200, json={"group": _group_wire()}) + item_result = gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) + + assert list_result.groups == () + assert item_result.id == GROUP_ID + assert http_client.request_calls == [ + call( + "GET", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + call( + "GET", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups/{GROUP_ID}", + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + ] + + +def test_group_mutations_inject_actor_and_use_expected_version(gateway_factory: GatewayFactory) -> None: + gateway, http_client = gateway_factory(base_url="") + http_client.response = httpx.Response(200, json={"group": _group_wire(version="1")}) + gateway.create_group( + TENANT_ID, + name="Office", + description="Office egress", + allowed_cidrs=["203.0.113.7/32"], + actor_account_id=ACCOUNT_ID, + ) + http_client.response = httpx.Response(200, json={"group": _group_wire(version="2")}) + gateway.update_group( + TENANT_ID, + GROUP_ID, + name="Office", + description="Updated office egress", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + http_client.response = httpx.Response(200, json={"deleted": True}) + gateway.delete_group( + TENANT_ID, + GROUP_ID, + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) + + assert http_client.request_calls == [ + call( + "POST", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups", + json={ + "name": "Office", + "description": "Office egress", + "allowed_cidrs": ["203.0.113.7/32"], + "actor_account_id": ACCOUNT_ID, + }, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + call( + "PUT", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups/{GROUP_ID}", + json={ + "name": "Office", + "description": "Updated office egress", + "allowed_cidrs": ["203.0.113.0/24"], + "expected_version": 1, + "actor_account_id": ACCOUNT_ID, + }, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + call( + "DELETE", + f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups/{GROUP_ID}", + json=None, + params={"expected_version": 2, "actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + ] + + +def test_app_config_read_and_atomic_write_use_tenant_and_app_path(gateway_factory: GatewayFactory) -> None: + gateway, http_client = gateway_factory(base_url="") + http_client.response = httpx.Response( + 200, json={"tenant_id": TENANT_ID, "app_id": APP_ID, "entitled": True, "binding": None} + ) + gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID) + http_client.response = httpx.Response( + 200, + json={ + "binding": _binding_wire( + enabled=True, group_id=GROUP_ID, access_points=["webapp", "service_api"], version="1" + ) + }, + ) + gateway.update_app_binding( + TENANT_ID, + APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=0, + actor_account_id=ACCOUNT_ID, + ) + http_client.response = httpx.Response( + 200, + json={ + "binding": _binding_wire( + enabled=False, group_id=GROUP_ID, access_points=["webapp", "service_api"], version="2" + ) + }, + ) + gateway.update_app_binding( + TENANT_ID, + APP_ID, + enabled=False, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + + endpoint = f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group" + assert http_client.request_calls == [ + call( + "GET", + endpoint, + json=None, + params={"actor_account_id": ACCOUNT_ID}, + headers=HEADERS, + follow_redirects=True, + ), + call( + "PUT", + endpoint, + json={ + "enabled": True, + "group_id": GROUP_ID, + "access_points": ["webapp", "service_api"], + "expected_version": 0, + "actor_account_id": ACCOUNT_ID, + }, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + call( + "PUT", + endpoint, + json={ + "enabled": False, + "group_id": GROUP_ID, + "access_points": ["webapp", "service_api"], + "expected_version": 1, + "actor_account_id": ACCOUNT_ID, + }, + params=None, + headers=HEADERS, + follow_redirects=True, + ), + ] + + +def test_app_lifecycle_cleanup_uses_internal_secret_endpoint(gateway_factory: GatewayFactory) -> None: + gateway, http_client = gateway_factory(base_url="") + response = httpx.Response(httpx.codes.OK, json={"deleted": True}) + http_client.response = response + + result = gateway.cleanup_app_binding(TENANT_ID, APP_ID) + + assert result is True + assert http_client.request_calls == [ + call( + "DELETE", + f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group-binding", + json=None, + params=None, + headers=HEADERS, + follow_redirects=True, + ) + ] + + +@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500, 503]) +def test_network_access_group_request_preserves_upstream_status( + status_code: int, gateway_factory: GatewayFactory +) -> None: + gateway, _ = gateway_factory() + response = httpx.Response(status_code) + with ( + patch.object(gateway, "_send_http_request", return_value=response), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + gateway._send_request("GET", "/groups") + + assert exc_info.value.status_code == status_code + + +def test_network_access_group_request_preserves_upstream_reason(gateway_factory: GatewayFactory) -> None: + gateway, _ = gateway_factory() + response = httpx.Response( + httpx.codes.CONFLICT, json={"code": 409, "reason": "NETWORK_ACCESS_VERSION_CONFLICT", "message": "internal"} + ) + with ( + patch.object(gateway, "_send_http_request", return_value=response), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + gateway._send_request("DELETE", "/groups/id") + + assert exc_info.value.status_code == httpx.codes.CONFLICT + assert exc_info.value.reason == "NETWORK_ACCESS_VERSION_CONFLICT" + + +def test_network_access_group_request_maps_transport_failure_to_service_unavailable( + gateway_factory: GatewayFactory, +) -> None: + gateway, _ = gateway_factory() + request = httpx.Request("GET", "https://network-access.internal/v1/groups") + with ( + patch.object( + gateway, + "_send_http_request", + side_effect=httpx.ConnectError("unavailable", request=request), + ), + pytest.raises(NetworkAccessGroupUpstreamError) as exc_info, + ): + gateway._send_request("GET", "/groups") + + assert exc_info.value.status_code == httpx.codes.SERVICE_UNAVAILABLE + + +def test_network_access_group_request_rejects_non_object_response(gateway_factory: GatewayFactory) -> None: + gateway, _ = gateway_factory() + response = httpx.Response(httpx.codes.OK, json=list[object]()) + with ( + patch.object(gateway, "_send_http_request", return_value=response), + pytest.raises(NetworkAccessGroupInvalidResponseError), + ): + gateway._send_request("GET", "/groups") + + +@pytest.mark.parametrize("body", [b"{", b"\xff", b""]) +def test_invalid_json_is_rejected_after_transport_receives_raw_response( + gateway_factory: GatewayFactory, body: bytes +) -> None: + gateway, client = gateway_factory() + client.response = httpx.Response(200, content=body, headers={"Content-Type": "application/json"}) + + with pytest.raises(NetworkAccessGroupInvalidResponseError) as raised: + gateway._send_request("GET", "/groups") + + assert isinstance(raised.value.__cause__, ValueError) + assert len(client.wire_requests) == 1 + assert not client.is_closed + + +@pytest.mark.parametrize("plan", [CloudPlan.PROFESSIONAL, CloudPlan.TEAM]) +def test_billing_entitlement_gateway_accepts_paid_plans(plan: CloudPlan) -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with patch( + "services.network_access_group_gateway.BillingService.get_info", + return_value={"subscription": {"plan": plan}}, + ) as get_info: + assert gateway.is_paid_plan(TENANT_ID) is True + + get_info.assert_called_once_with(TENANT_ID, exclude_vector_space=True) + + +def test_billing_entitlement_gateway_rejects_unpaid_plan() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with patch( + "services.network_access_group_gateway.BillingService.get_info", + return_value={"subscription": {"plan": CloudPlan.SANDBOX}}, + ): + assert gateway.is_paid_plan(TENANT_ID) is False + + +def test_billing_entitlement_gateway_maps_unavailable_billing_service() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with ( + patch( + "services.network_access_group_gateway.BillingService.get_info", + side_effect=httpx.ConnectError("unavailable"), + ), + pytest.raises(NetworkAccessGroupEntitlementUnavailableError), + ): + gateway.is_paid_plan(TENANT_ID) + + +def test_billing_entitlement_gateway_maps_billing_http_status_error() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with ( + patch( + "services.network_access_group_gateway.BillingService.get_info", + side_effect=_BillingHTTPStatusError("unavailable", 503), + ), + pytest.raises(NetworkAccessGroupEntitlementUnavailableError), + ): + gateway.is_paid_plan(TENANT_ID) + + +def test_billing_entitlement_gateway_does_not_mask_unexpected_value_error() -> None: + gateway = BillingNetworkAccessGroupEntitlementGateway() + with ( + patch( + "services.network_access_group_gateway.BillingService.get_info", + side_effect=ValueError("programming error"), + ), + pytest.raises(ValueError, match="programming error"), + ): + gateway.is_paid_plan(TENANT_ID) + + +def test_protojson_defaults_are_materialized_before_service_boundary(gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + client.response = httpx.Response(200, json={"tenantId": TENANT_ID}) + groups = gateway.list_groups(TENANT_ID, ACCOUNT_ID) + assert groups.entitled is False + assert groups.groups == () + + for binding in (None, _binding_wire()): + client.response = httpx.Response(200, json={"tenantId": TENANT_ID, "appId": APP_ID, "binding": binding}) + result = gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID) + assert result.entitled is False + assert result.effective_enabled is False + if binding is None: + assert result.binding is None + else: + assert result.binding is not None + assert result.binding.enabled is False + assert result.binding.access_points == () + assert result.binding.group_id is None + + client.response = httpx.Response(200, json={"tenantId": TENANT_ID, "appId": APP_ID}) + assert gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID).binding is None + client.response = httpx.Response(200, json={}) + assert gateway.cleanup_app_binding(TENANT_ID, APP_ID) is False + + +def test_policy_protojson_aliases_and_count_defaults_are_decoded_once(gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + wire = { + "id": GROUP_ID, + "tenantId": TENANT_ID, + "name": "Office", + "allowedCidrs": ["203.0.113.7/32"], + "version": "9007199254740993", + "usedByAppIds": [APP_ID], + "createdAt": "2026-09-01T00:00:00Z", + "updatedAt": "2026-09-01T00:00:00Z", + } + response = httpx.Response(200, json={"group": wire}) + client.response = response + result = gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) + assert result.version == 9007199254740993 + assert result.used_by_count == 1 # Legacy replies without a count retain the reference fallback. + assert result.enforcing_count == 0 + assert result.allowed_cidrs == ("203.0.113.7/32",) + assert result.app_ids == (APP_ID,) + assert result.description == "" + assert result.updated_by_account_id is None + assert "enforcing_count" not in wire + + +@pytest.mark.parametrize("alias", ["group_id", "groupId", "policy_id", "policyId"]) +@pytest.mark.parametrize("group_id", [GROUP_ID, None, ""]) +def test_binding_policy_aliases_and_nullable_references( + alias: str, group_id: str | None, gateway_factory: GatewayFactory +) -> None: + gateway, client = gateway_factory() + response = httpx.Response( + 200, json={"tenantId": TENANT_ID, "appId": APP_ID, "binding": _binding_wire(**{alias: group_id})} + ) + client.response = response + binding = gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID).binding + assert binding is not None + assert binding.group_id == (group_id or None) + + +def test_unknown_scopes_reach_service_without_aliases_or_mutable_payload(gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + scopes = ["webapp", "future_scope", "trigger"] + response = httpx.Response( + 200, + json={ + "effectiveEnabled": True, + "binding": _binding_wire(enabled=True, policyId=GROUP_ID, accessPoints=scopes), + }, + ) + client.response = response + result = gateway.update_app_binding( + TENANT_ID, + APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + actor_account_id=ACCOUNT_ID, + ) + assert result.binding.access_points == ("webapp", "future_scope", "trigger") + assert result.binding.group_id == GROUP_ID + assert result.effective_enabled is True + scopes.clear() + assert result.binding.access_points == ("webapp", "future_scope", "trigger") + + +@pytest.mark.parametrize("value", [True, False, 1.0, "1.5", "invalid", 0, -1, 2**63, str(2**63)]) +def test_invalid_versions_are_rejected_at_gateway(value: object, gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + response = httpx.Response(200, json={"group": _group_wire(version=value)}) + client.response = response + with pytest.raises(NetworkAccessGroupInvalidResponseError): + gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) + + +@pytest.mark.parametrize("version", [1, "1", 2**63 - 1, str(2**63 - 1)]) +def test_int64_versions_do_not_lose_precision(version: int | str, gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + response = httpx.Response(200, json={"group": _group_wire(version=version)}) + client.response = response + assert gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID).version == int(version) + + +@pytest.mark.parametrize("field", ["entitled", "effective_enabled", "enabled"]) +@pytest.mark.parametrize("value", ["false", "true", 0, 1, None]) +def test_wire_flags_are_strict_booleans(field: str, value: object, gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + binding = _binding_wire() + payload: dict[str, object] = {"tenant_id": TENANT_ID, "app_id": APP_ID, "binding": binding} + (binding if field == "enabled" else payload)[field] = value + response = httpx.Response(200, json=payload) + client.response = response + with pytest.raises(NetworkAccessGroupInvalidResponseError): + gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID) + + +@pytest.mark.parametrize("alias", ["groupId", "policy_id", "policyId"]) +def test_conflicting_policy_aliases_fail_instead_of_selecting_one(alias: str, gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + response = httpx.Response( + 200, + json={ + "tenant_id": TENANT_ID, + "app_id": APP_ID, + "binding": _binding_wire(group_id=GROUP_ID, **{alias: APP_ID}), + }, + ) + client.response = response + with pytest.raises(NetworkAccessGroupInvalidResponseError): + gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID) + client.response = httpx.Response( + 200, + json={ + "tenant_id": TENANT_ID, + "app_id": APP_ID, + "binding": _binding_wire(group_id=GROUP_ID, **{alias: GROUP_ID}), + }, + ) + binding = gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID).binding + assert binding is not None + assert binding.group_id == GROUP_ID + + +@pytest.mark.parametrize("payload", [{}, {"group": None}, {"group": {"version": "1"}}]) +def test_existing_policy_identity_fields_are_never_fabricated( + payload: dict[str, object], gateway_factory: GatewayFactory +) -> None: + gateway, client = gateway_factory() + response = httpx.Response(200, json=payload) + client.response = response + with pytest.raises(NetworkAccessGroupInvalidResponseError): + gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) + + +@pytest.mark.parametrize("field", ["used_by_count", "enforcing_count"]) +@pytest.mark.parametrize("value", [True, 0.0, "0.5", -1]) +def test_invalid_policy_counts_are_rejected(field: str, value: object, gateway_factory: GatewayFactory) -> None: + gateway, client = gateway_factory() + response = httpx.Response(200, json={"group": _group_wire(**{field: value})}) + client.response = response + with pytest.raises(NetworkAccessGroupInvalidResponseError): + gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID) diff --git a/api/tests/unit_tests/services/test_network_access_group_service.py b/api/tests/unit_tests/services/test_network_access_group_service.py new file mode 100644 index 00000000000000..26e32da620b01c --- /dev/null +++ b/api/tests/unit_tests/services/test_network_access_group_service.py @@ -0,0 +1,904 @@ +from collections.abc import Callable +from datetime import UTC, datetime +from typing import NamedTuple +from unittest.mock import MagicMock, create_autospec + +import pytest + +from machinery.context import RequestContext +from services.entities.network_access_group_entities import ( + NetworkAccessAppConfig, + NetworkAccessAppStatusSnapshot, + NetworkAccessBinding, + NetworkAccessBindingUpdate, + NetworkAccessCurrentIPCheck, + NetworkAccessGroup, + NetworkAccessGroupList, +) +from services.network_access_group_service import ( + NetworkAccessGroupAccessDeniedError, + NetworkAccessGroupAppNotFoundError, + NetworkAccessGroupAppQuery, + NetworkAccessGroupAppQueryError, + NetworkAccessGroupAppRecord, + NetworkAccessGroupControlPlane, + NetworkAccessGroupEntitlement, + NetworkAccessGroupInvalidPolicyError, + NetworkAccessGroupService, + NetworkAccessGroupUnsupportedAccessPointsError, + NetworkAccessGroupUnsupportedAppModeError, + NetworkAccessGroupUpstreamError, + WorkspaceMembershipRoleQuery, +) + +WORKSPACE_ID = "workspace-1" +ACCOUNT_ID = "account-1" +APP_ID = "app-1" +GROUP_ID = "group-1" +NOW = datetime(2026, 9, 1, tzinfo=UTC) + + +def _group( + *, + group_id: str = GROUP_ID, + app_ids: tuple[str, ...] = (), + version: int = 1, + allowed_cidrs: tuple[str, ...] = ("203.0.113.0/24",), + used_by_count: int | None = None, + enforcing_count: int = 0, +) -> NetworkAccessGroup: + return NetworkAccessGroup( + group_id, + WORKSPACE_ID, + "Office", + "", + allowed_cidrs, + version, + len(app_ids) if used_by_count is None else used_by_count, + enforcing_count, + app_ids, + ACCOUNT_ID, + NOW, + NOW, + ) + + +def _binding( + *, enabled: bool = False, group_id: str | None = None, access_points: tuple[str, ...] = () +) -> NetworkAccessBinding: + return NetworkAccessBinding( + "binding", WORKSPACE_ID, APP_ID, enabled, group_id, access_points, 1, ACCOUNT_ID, NOW, NOW + ) + + +def _config( + binding: NetworkAccessBinding | None = None, *, effective_enabled: bool = False, entitled: bool = True +) -> NetworkAccessAppConfig: + return NetworkAccessAppConfig(WORKSPACE_ID, APP_ID, entitled, effective_enabled, binding) + + +def _groups(groups: tuple[NetworkAccessGroup, ...] = (), *, entitled: bool = True) -> NetworkAccessGroupList: + return NetworkAccessGroupList(WORKSPACE_ID, entitled, groups) + + +class _Harness(NamedTuple): + service: NetworkAccessGroupService + control_plane: MagicMock + apps: MagicMock + memberships: MagicMock + entitlement: MagicMock + + +def _context() -> RequestContext: + return RequestContext( + request_id="request-1", + trace_id=None, + account_id=ACCOUNT_ID, + active_workspace_id=WORKSPACE_ID, + ) + + +def _app( + *, + app_id: str = APP_ID, + mode: str = "workflow", + name: str = "App", + bound_agent_id: str | None = None, +) -> NetworkAccessGroupAppRecord: + return NetworkAccessGroupAppRecord( + id=app_id, + mode=mode, + name=name, + icon=f"{app_id}.png", + icon_type="image", + icon_background="#FFFFFF", + bound_agent_id=bound_agent_id, + ) + + +def _harness(*, role: str | None = "owner", paid: bool = True, app_mode: str = "workflow") -> _Harness: + control_plane = create_autospec(NetworkAccessGroupControlPlane, instance=True, spec_set=True) + apps = create_autospec(NetworkAccessGroupAppQuery, instance=True, spec_set=True) + memberships = create_autospec(WorkspaceMembershipRoleQuery, instance=True, spec_set=True) + entitlement = create_autospec(NetworkAccessGroupEntitlement, instance=True, spec_set=True) + memberships.get_account_role.return_value = role + entitlement.is_paid_plan.return_value = paid + apps.get_manageable_app.return_value = _app(mode=app_mode) + apps.list_apps.return_value = list[NetworkAccessGroupAppRecord]() + control_plane.list_groups.return_value = _groups() + for method in (control_plane.get_group, control_plane.create_group, control_plane.update_group): + method.return_value = _group() + control_plane.get_app_binding.return_value = _config() + control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot(True, False, False, ()) + control_plane.update_app_binding.return_value = NetworkAccessBindingUpdate(_binding(), False) + control_plane.delete_group.return_value = True + control_plane.cleanup_app_binding.return_value = True + return _Harness( + service=NetworkAccessGroupService( + control_plane=control_plane, + apps=apps, + memberships=memberships, + entitlement=entitlement, + ), + control_plane=control_plane, + apps=apps, + memberships=memberships, + entitlement=entitlement, + ) + + +@pytest.mark.parametrize("role", ["normal", "dataset_operator", None]) +@pytest.mark.parametrize( + "read", + [ + pytest.param(lambda service: service.list_groups(_context()), id="list"), + pytest.param(lambda service: service.get_group(_context(), group_id=GROUP_ID), id="detail"), + ], +) +def test_reads_reject_non_privileged_persisted_roles_before_control_plane( + role: str | None, read: Callable[[NetworkAccessGroupService], object] +) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + read(harness.service) + + harness.memberships.get_account_role.assert_called_once_with( + tenant_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + assert harness.control_plane.method_calls == [] + + +Mutation = Callable[[NetworkAccessGroupService, RequestContext], object] + + +@pytest.mark.parametrize( + "mutation", + [ + pytest.param( + lambda service, context: service.create_group( + context, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ), + id="create-group", + ), + pytest.param( + lambda service, context: service.update_group( + context, + group_id=GROUP_ID, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ), + id="update-group", + ), + pytest.param( + lambda service, context: service.delete_group( + context, + group_id=GROUP_ID, + expected_version=1, + ), + id="delete-group", + ), + pytest.param( + lambda service, context: service.update_app_binding( + context, + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ), + id="update-app-binding", + ), + ], +) +@pytest.mark.parametrize("role", ["normal", "dataset_operator", None]) +def test_mutations_reject_non_privileged_persisted_roles_before_control_plane( + role: str | None, + mutation: Mutation, +) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + mutation(harness.service, _context()) + + harness.memberships.get_account_role.assert_called_once_with( + tenant_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + assert harness.control_plane.method_calls == [] + + +@pytest.mark.parametrize("role", ["owner", "admin", "editor"]) +def test_policy_reads_allow_persisted_owner_admin_and_editor(role: str) -> None: + harness = _harness(role=role) + harness.control_plane.list_groups.return_value = _groups() + harness.control_plane.get_group.return_value = _group() + + harness.service.list_groups(_context()) + harness.service.get_group(_context(), group_id=GROUP_ID) + + harness.control_plane.list_groups.assert_called_once_with(WORKSPACE_ID, ACCOUNT_ID) + harness.control_plane.get_group.assert_called_once_with(WORKSPACE_ID, GROUP_ID, ACCOUNT_ID) + + +@pytest.mark.parametrize( + "mutation", + [ + pytest.param( + lambda service, context: service.create_group( + context, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ), + id="create-group", + ), + pytest.param( + lambda service, context: service.update_group( + context, + group_id=GROUP_ID, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + expected_version=1, + ), + id="update-group", + ), + pytest.param( + lambda service, context: service.delete_group( + context, + group_id=GROUP_ID, + expected_version=1, + ), + id="delete-group", + ), + ], +) +def test_policy_mutations_reject_editor_before_control_plane(mutation: Mutation) -> None: + harness = _harness(role="editor") + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + mutation(harness.service, _context()) + + assert harness.control_plane.method_calls == [] + + +@pytest.mark.parametrize("role", ["owner", "admin", "editor"]) +def test_app_binding_get_and_put_allow_privileged_roles(role: str) -> None: + harness = _harness(role=role) + harness.control_plane.get_app_binding.return_value = _config() + harness.control_plane.update_app_binding.return_value = NetworkAccessBindingUpdate( + _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp",)), True + ) + + harness.service.get_app_binding(_context(), app_id=APP_ID) + harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ) + + harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.update_app_binding.assert_called_once() + + +@pytest.mark.parametrize( + "operation", + [ + pytest.param( + lambda service, context: service.get_app_binding(context, app_id=APP_ID), + id="get", + ), + pytest.param( + lambda service, context: service.update_app_binding( + context, + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ), + id="put", + ), + ], +) +@pytest.mark.parametrize("role", ["normal", "dataset_operator", None]) +def test_app_binding_rejects_disallowed_roles_before_app_lookup(operation: Mutation, role: str | None) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + operation(harness.service, _context()) + + harness.apps.get_manageable_app.assert_not_called() + assert harness.control_plane.method_calls == [] + + +def test_normal_member_can_read_only_app_entry_status() -> None: + harness = _harness(role="normal") + harness.control_plane.get_app_status.return_value = NetworkAccessAppStatusSnapshot( + entitled=True, configured=True, enabled=True, access_points=("webapp", "service_api") + ) + + status = harness.service.get_app_status(_context(), app_id=APP_ID) + + assert status.entitled is True + assert status.configured is True + assert status.enabled is True + assert status.covered_count == 2 + assert status.available_count == 4 + assert not hasattr(status, "group_id") + assert not hasattr(status, "allowed_cidrs") + harness.control_plane.get_app_status.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.get_app_binding.assert_not_called() + + +@pytest.mark.parametrize("role", ["dataset_operator", None]) +def test_app_entry_status_rejects_roles_without_app_access(role: str | None) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.apps.get_manageable_app.assert_not_called() + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_status_rejects_missing_tenant_scoped_app_before_upstream_read() -> None: + harness = _harness(role="normal") + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_status_rejects_unsupported_app_mode_before_upstream_read() -> None: + harness = _harness(role="normal", app_mode="unknown") + + with pytest.raises(NetworkAccessGroupUnsupportedAppModeError): + harness.service.get_app_status(_context(), app_id=APP_ID) + + harness.control_plane.get_app_status.assert_not_called() + harness.control_plane.get_app_binding.assert_not_called() + + +def test_mutation_does_not_repeat_http_paid_plan_admission() -> None: + harness = _harness() + harness.control_plane.create_group.return_value = _group() + + harness.service.create_group( + _context(), + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ) + + harness.entitlement.is_paid_plan.assert_not_called() + + +@pytest.mark.parametrize("role", ["owner", "admin"]) +def test_public_use_cases_forward_workspace_actor_and_request_parameters(role: str) -> None: + harness = _harness(role=role) + context = _context() + + harness.service.list_groups(context) + harness.service.create_group( + context, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + ) + harness.service.get_group(context, group_id=GROUP_ID) + harness.service.update_group( + context, + group_id=GROUP_ID, + name="Office v2", + description="Updated network", + allowed_cidrs=["203.0.113.7/32"], + expected_version=2, + ) + harness.service.delete_group(context, group_id=GROUP_ID, expected_version=3) + harness.service.get_app_binding(context, app_id=APP_ID) + harness.service.update_app_binding( + context, + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=4, + ) + + harness.control_plane.list_groups.assert_called_once_with(WORKSPACE_ID, ACCOUNT_ID) + harness.control_plane.create_group.assert_called_once_with( + WORKSPACE_ID, + name="Office", + description="Office network", + allowed_cidrs=["203.0.113.0/24"], + actor_account_id=ACCOUNT_ID, + ) + harness.control_plane.get_group.assert_called_once_with(WORKSPACE_ID, GROUP_ID, ACCOUNT_ID) + harness.control_plane.update_group.assert_called_once_with( + WORKSPACE_ID, + GROUP_ID, + name="Office v2", + description="Updated network", + allowed_cidrs=["203.0.113.7/32"], + expected_version=2, + actor_account_id=ACCOUNT_ID, + ) + harness.control_plane.delete_group.assert_called_once_with( + WORKSPACE_ID, + GROUP_ID, + expected_version=3, + actor_account_id=ACCOUNT_ID, + ) + harness.control_plane.get_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID, ACCOUNT_ID) + harness.control_plane.update_app_binding.assert_called_once_with( + WORKSPACE_ID, + APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp", "service_api"], + expected_version=4, + actor_account_id=ACCOUNT_ID, + ) + + +def test_cleanup_app_binding_forwards_without_console_admission() -> None: + harness = _harness() + harness.control_plane.cleanup_app_binding.return_value = True + + result = harness.service.cleanup_app_binding(workspace_id=WORKSPACE_ID, app_id=APP_ID) + + assert result is True + harness.control_plane.cleanup_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID) + harness.memberships.get_account_role.assert_not_called() + harness.entitlement.is_paid_plan.assert_not_called() + + +@pytest.mark.parametrize( + ("upstream_entitled", "paid", "expected"), + [ + (False, False, False), + (False, True, False), + (True, False, False), + (True, True, True), + ], +) +def test_list_effective_entitlement_requires_both_sources( + upstream_entitled: bool, + paid: bool, + expected: bool, +) -> None: + harness = _harness(paid=paid) + harness.control_plane.list_groups.return_value = _groups(entitled=upstream_entitled) + + result = harness.service.list_groups(_context()) + + assert result.entitled is expected + + +@pytest.mark.parametrize( + ("mode", "available_access_points"), + [ + ("workflow", ["webapp", "service_api", "mcp", "trigger"]), + ("advanced-chat", ["webapp", "service_api", "mcp"]), + ("chat", ["webapp", "service_api", "mcp"]), + ("completion", ["webapp", "service_api", "mcp"]), + ("agent-chat", ["webapp", "service_api", "mcp"]), + ("agent", ["webapp", "service_api"]), + ], +) +def test_app_binding_reports_access_points_for_each_supported_mode( + mode: str, + available_access_points: list[str], +) -> None: + harness = _harness(app_mode=mode) + harness.control_plane.get_app_binding.return_value = _config() + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result.available_access_points == tuple(available_access_points) + + +def test_app_binding_rejects_unsupported_app_mode_before_control_plane() -> None: + harness = _harness(app_mode="site") + + with pytest.raises(NetworkAccessGroupUnsupportedAppModeError, match="'site'"): + harness.service.get_app_binding(_context(), app_id=APP_ID) + + harness.control_plane.get_app_binding.assert_not_called() + + +def test_app_binding_rejects_missing_or_inaccessible_app_before_control_plane() -> None: + harness = _harness() + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.get_app_binding(_context(), app_id=APP_ID) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.memberships.get_account_role.assert_called_once_with( + tenant_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + harness.control_plane.get_app_binding.assert_not_called() + + +def test_update_app_binding_rejects_missing_or_inaccessible_app_before_control_plane() -> None: + harness = _harness() + harness.apps.get_manageable_app.return_value = None + + with pytest.raises(NetworkAccessGroupAppNotFoundError): + harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["webapp"], + expected_version=1, + ) + + harness.apps.get_manageable_app.assert_called_once_with(workspace_id=WORKSPACE_ID, app_id=APP_ID) + harness.memberships.get_account_role.assert_called_once_with( + tenant_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + harness.control_plane.update_app_binding.assert_not_called() + + +def test_update_app_binding_rejects_access_points_not_supported_by_mode() -> None: + harness = _harness(app_mode="agent") + + with pytest.raises(NetworkAccessGroupUnsupportedAccessPointsError) as exc_info: + harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=True, + group_id=GROUP_ID, + access_points=["trigger", "mcp"], + expected_version=1, + ) + + assert exc_info.value.access_points == ("mcp", "trigger") + harness.control_plane.update_app_binding.assert_not_called() + + +def test_disabled_binding_stays_disabled() -> None: + harness = _harness(app_mode="chat") + harness.control_plane.get_app_binding.return_value = _config(_binding()) + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result.binding == _binding() + assert result.effective_enabled is False + + +def test_app_binding_filters_stale_and_future_scopes_without_mutating_snapshot() -> None: + harness = _harness(app_mode="chat") + saved = _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp", "trigger", "mcp", "future")) + harness.control_plane.get_app_binding.return_value = _config(saved) + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result.binding == _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp", "mcp")) + assert result.effective_enabled is False + assert saved.access_points == ("webapp", "trigger", "mcp", "future") + + +def test_group_payloads_are_enriched_with_tenant_scoped_app_metadata() -> None: + harness = _harness() + harness.control_plane.list_groups.return_value = _groups( + ( + _group(app_ids=("app-2", "missing-app", "app-1")), + _group(group_id="group-2", app_ids=("app-1",)), + ) + ) + harness.apps.list_apps.return_value = [ + _app(app_id="app-1", name="One"), + _app(app_id="app-2", name="Two"), + ] + + result = harness.service.list_groups(_context()) + + groups = result.groups + assert groups[0].app_ids == ("app-2", "missing-app", "app-1") + assert groups[0].used_by_count == 3 + assert [app.id for app in groups[0].apps] == ["app-2", "app-1"] + assert groups[1].used_by_count == 1 + assert groups[1].apps == (_app(app_id="app-1", name="One"),) + call_kwargs = harness.apps.list_apps.call_args.kwargs + assert call_kwargs["workspace_id"] == WORKSPACE_ID + assert set(call_kwargs["app_ids"]) == {"app-1", "app-2", "missing-app"} + + +def test_app_query_failure_degrades_group_enrichment_to_empty_apps() -> None: + harness = _harness() + harness.control_plane.get_group.return_value = _group(app_ids=(APP_ID,)) + harness.apps.list_apps.side_effect = NetworkAccessGroupAppQueryError + + result = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result.app_ids == (APP_ID,) + assert result.apps == () + + +@pytest.mark.parametrize(("paid", "expected"), [(True, True), (False, False)]) +def test_app_effective_enabled_is_bounded_by_final_entitlement(paid: bool, expected: bool) -> None: + harness = _harness(paid=paid, app_mode="chat") + saved = _binding(enabled=True, group_id=GROUP_ID, access_points=("webapp", "service_api")) + harness.control_plane.get_app_binding.return_value = _config(saved, effective_enabled=True) + + result = harness.service.get_app_binding(_context(), app_id=APP_ID) + + assert result.effective_enabled is expected + assert result.binding is not None + assert result.binding.enabled is True + assert harness.control_plane.get_app_binding.return_value.entitled is True + + +def test_app_effective_enabled_does_not_reenable_an_incomplete_filtered_binding() -> None: + harness = _harness(app_mode="agent") + harness.control_plane.update_app_binding.return_value = NetworkAccessBindingUpdate( + _binding(enabled=True, group_id=GROUP_ID, access_points=("mcp",)), True + ) + + result = harness.service.update_app_binding( + _context(), + app_id=APP_ID, + enabled=False, + group_id=GROUP_ID, + access_points=[], + expected_version=1, + ) + + assert result.binding.enabled is True + assert result.binding.group_id == GROUP_ID + assert result.binding.access_points == () + assert result.effective_enabled is False + + +@pytest.mark.parametrize(("paid", "expected"), [(True, 2), (False, 0)]) +def test_group_enforcing_count_is_normalized_and_bounded_by_final_entitlement( + paid: bool, + expected: int, +) -> None: + harness = _harness(paid=paid) + harness.control_plane.list_groups.return_value = _groups((_group(enforcing_count=2, used_by_count=3),)) + + result = harness.service.list_groups(_context()) + + assert result.groups[0].enforcing_count == expected + + +def test_group_with_zero_enforcement_retains_zero() -> None: + harness = _harness() + harness.control_plane.get_group.return_value = _group() + + result = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result.enforcing_count == 0 + + +def test_group_enrichment_includes_agent_routing_metadata() -> None: + harness = _harness() + harness.control_plane.get_group.return_value = _group(app_ids=(APP_ID,)) + harness.apps.list_apps.return_value = [_app(app_id=APP_ID, mode="agent", name="Agent", bound_agent_id="agent-1")] + + result = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result.apps == (_app(app_id=APP_ID, mode="agent", name="Agent", bound_agent_id="agent-1"),) + + +def test_current_ip_check_authorizes_and_loads_tenant_policy_before_reading_ip() -> None: + harness = _harness(role="editor") + order: list[str] = [] + harness.memberships.get_account_role.side_effect = lambda **_kwargs: order.append("role") or "editor" + harness.control_plane.get_group.side_effect = lambda *_args: ( + order.append("policy") or _group(version=7, allowed_cidrs=("2001:db8::/32",)) + ) + + result = harness.service.check_current_ip( + _context(), + group_id=GROUP_ID, + client_ip_supplier=lambda: order.append("ip") or "2001:db8::42", + ) + + assert order == ["role", "policy", "ip"] + assert result == NetworkAccessCurrentIPCheck("2001:db8::42", True, 7) + + +@pytest.mark.parametrize("role", ["owner", "admin", "editor"]) +@pytest.mark.parametrize("paid", [True, False], ids=["paid", "lapsed-or-free"]) +def test_current_ip_read_needs_no_policy_and_preserves_read_admission(role: str, paid: bool) -> None: + harness = _harness(role=role, paid=paid) + order: list[str] = [] + harness.memberships.get_account_role.side_effect = lambda **_kwargs: order.append("role") or role + + result = harness.service.get_current_ip( + _context(), + client_ip_supplier=lambda: order.append("ip") or "2001:db8::42", + ) + + assert result == "2001:db8::42" + assert order == ["role", "ip"] + harness.memberships.get_account_role.assert_called_once_with( + tenant_id=WORKSPACE_ID, + account_id=ACCOUNT_ID, + ) + assert harness.control_plane.method_calls == [] + assert harness.apps.method_calls == [] + harness.entitlement.is_paid_plan.assert_not_called() + + +def _unexpected_client_ip_read() -> str: + pytest.fail("Client IP must not be read before role and policy admission") + + +@pytest.mark.parametrize("role", ["normal", "dataset_operator", "unknown", None]) +def test_current_ip_read_rejects_disallowed_persisted_roles_before_reading_ip(role: str | None) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + harness.service.get_current_ip(_context(), client_ip_supplier=_unexpected_client_ip_read) + + assert harness.control_plane.method_calls == [] + assert harness.apps.method_calls == [] + harness.entitlement.is_paid_plan.assert_not_called() + + +@pytest.mark.parametrize("role", ["normal", "dataset_operator", None]) +def test_current_ip_check_rejects_role_before_policy_or_ip_lookup(role: str | None) -> None: + harness = _harness(role=role) + + with pytest.raises(NetworkAccessGroupAccessDeniedError): + harness.service.check_current_ip(_context(), group_id=GROUP_ID, client_ip_supplier=_unexpected_client_ip_read) + + harness.control_plane.get_group.assert_not_called() + + +def test_current_ip_check_does_not_read_ip_when_tenant_scoped_policy_lookup_fails() -> None: + harness = _harness(role="admin") + harness.control_plane.get_group.side_effect = NetworkAccessGroupUpstreamError(404) + + with pytest.raises(NetworkAccessGroupUpstreamError): + harness.service.check_current_ip(_context(), group_id=GROUP_ID, client_ip_supplier=_unexpected_client_ip_read) + + +@pytest.mark.parametrize( + ("allowed_cidrs", "client_ip", "expected"), + [ + (["203.0.113.0/24"], "::ffff:203.0.113.7", True), + (["::ffff:203.0.113.0/120"], "203.0.113.7", True), + (["2001:db8::/32"], "2001:db8:1::1", True), + (["2001:db8::/48"], "2001:db9::1", False), + (["203.0.113.7"], "203.0.113.7", True), + (["203.0.113.7"], "203.0.113.8", False), + (["0.0.0.0"], "203.0.113.7", False), + (["2001:db8::1"], "2001:db8::1", True), + (["2001:db8::1"], "2001:db8::2", False), + (["2001:0DB8:0000:0000:0000:0000:0000:0001"], "2001:db8::1", True), + (["2001:db8::1"], "2001:0db8:0:0:0:0:0:1", True), + (["::"], "2001:db8::1", False), + (["203.0.113.77/24"], "203.0.113.7", True), + (["203.0.113.77/24"], "203.0.114.7", False), + (["2001:db8:1::beef/64"], "2001:db8:1::42", True), + (["2001:db8:1::beef/64"], "2001:db8:2::42", False), + (["::ffff:203.0.113.7"], "203.0.113.7", True), + (["::ffff:203.0.113.7"], "203.0.113.8", False), + (["::ffff:203.0.113.129/120"], "203.0.113.7", True), + (["::ffff:203.0.113.129/120"], "203.0.114.7", False), + (["::ffff:203.0.113.129/96"], "198.51.100.7", True), + (["::ffff:203.0.113.129/96"], "2001:db8::1", False), + ], +) +def test_current_ip_check_matches_ipv4_ipv6_and_mapped_addresses_consistently( + allowed_cidrs: list[str], + client_ip: str, + expected: bool, +) -> None: + harness = _harness() + harness.control_plane.get_group.return_value = _group(allowed_cidrs=tuple(allowed_cidrs)) + + result = harness.service.check_current_ip( + _context(), + group_id=GROUP_ID, + client_ip_supplier=lambda: client_ip, + ) + + assert result.allowed is expected + + +def test_current_ip_check_preserves_display_spelling_and_order_without_rewriting_policy() -> None: + harness = _harness() + display_entries = ( + "203.0.113.9", + "2001:0DB8:0000:0000:0000:0000:0000:0001", + "198.51.100.77/24", + "::ffff:203.0.113.129/120", + ) + group = _group(allowed_cidrs=display_entries) + harness.control_plane.get_group.return_value = group + + result = harness.service.check_current_ip(_context(), group_id=GROUP_ID, client_ip_supplier=lambda: "2001:db8::1") + displayed = harness.service.get_group(_context(), group_id=GROUP_ID) + + assert result.allowed is True + assert group.allowed_cidrs == display_entries + assert displayed.allowed_cidrs == display_entries + harness.control_plane.create_group.assert_not_called() + harness.control_plane.update_group.assert_not_called() + harness.control_plane.update_app_binding.assert_not_called() + + +@pytest.mark.parametrize( + "allowed_cidrs", + [ + [], + ["0.0.0.0/0", "not-a-cidr"], + ["fe80::1%eth0/128"], + ["fe80::1%eth0"], + ["::ffff:0:0/80"], + ["::ffff:203.0.113.7/95"], + ["203.0.113.7/255.255.255.0"], + ["203.0.113.7/0.0.0.255"], + ["203.0.113.7/"], + ["203.0.113.7/24/1"], + ["203.0.113.7/+24"], + ["203.0.113.7/24"], + ["203.0.113.7/33"], + ["2001:db8::1/129"], + ["192.168.01.1"], + ["2001:db8:::1"], + ["203.0.113.7 "], + [" 203.0.113.7"], + [""], + ["203.0.113.7", "not-an-ip"], + ["203.0.113.0/24", "::ffff:203.0.113.7/95"], + ], +) +def test_current_ip_check_rejects_malformed_policy_without_short_circuiting( + allowed_cidrs: list[str], +) -> None: + harness = _harness() + harness.control_plane.get_group.return_value = _group(allowed_cidrs=tuple(allowed_cidrs)) + + with pytest.raises(NetworkAccessGroupInvalidPolicyError): + harness.service.check_current_ip( + _context(), + group_id=GROUP_ID, + client_ip_supplier=lambda: "203.0.113.7", + ) diff --git a/api/tests/unit_tests/services/test_webapp_access_query_service.py b/api/tests/unit_tests/services/test_webapp_access_query_service.py index d2e0167c15164e..9212883b9ecd26 100644 --- a/api/tests/unit_tests/services/test_webapp_access_query_service.py +++ b/api/tests/unit_tests/services/test_webapp_access_query_service.py @@ -7,20 +7,23 @@ from enums import WebAppAccessMode from models.enums import CustomizeTokenStrategy -from models.model import Site +from models.model import App, AppModelConfig, Site from repositories.webapp_access_query_repository import WebAppAccessQueryRepository from services.webapp_access_query_service import ( WebAppAccessAppNotFoundError, WebAppAccessQueryService, WebAppAccessReferenceRequiredError, + WebAppAccessUnavailableError, ) @dataclass class AccessQueryStub: app_id: str | None = None + available: bool = True error: Exception | None = None calls: list[str] = field(default_factory=list) + availability_calls: list[str] = field(default_factory=list) def find_app_id_by_code(self, app_code: str) -> str | None: self.calls.append(app_code) @@ -28,6 +31,12 @@ def find_app_id_by_code(self, app_code: str) -> str | None: raise self.error return self.app_id + def is_app_available(self, app_id: str) -> bool: + self.availability_calls.append(app_id) + if self.error is not None: + raise self.error + return self.available + @dataclass class AccessPolicyStub: @@ -93,11 +102,81 @@ def test_disabled_auth_returns_public_before_resolving_app() -> None: assert fixture.policy.access_mode_calls == [] +@pytest.mark.parametrize("enabled", [True, False]) +def test_management_reads_configured_policy_without_publication_admission(enabled: bool) -> None: + fixture = _service(access=AccessQueryStub(available=False), enabled=enabled) + + result = fixture.service.get_configured_access_mode(app_id="draft-app") + + assert result is (WebAppAccessMode.PRIVATE if enabled else WebAppAccessMode.PUBLIC) + assert fixture.access.calls == [] + assert fixture.access.availability_calls == [] + assert fixture.policy.access_mode_calls == (["draft-app"] if enabled else []) + + +@pytest.mark.parametrize("enabled", [True, False]) +@pytest.mark.parametrize("reference", ["code", "id"]) +def test_public_identity_resolution_does_not_load_authentication_settings(enabled: bool, reference: str) -> None: + fixture = _service(access=AccessQueryStub(app_id="resolved-id"), enabled=enabled) + result = fixture.service.resolve_app_id( + **({"app_code": "site-code"} if reference == "code" else {"app_id": "resolved-id"}) + ) + assert result == "resolved-id" + assert fixture.policy.access_mode_calls == [] + assert fixture.policy.permission_calls == [] + assert fixture.access.calls == (["site-code"] if reference == "code" else []) + assert fixture.access.availability_calls == (["resolved-id"] if reference == "id" else []) + + +def test_public_identity_requires_a_reference_even_when_auth_disabled() -> None: + fixture = _service(enabled=False) + with pytest.raises(WebAppAccessReferenceRequiredError): + fixture.service.resolve_app_id() + assert fixture.policy.access_mode_calls == [] + assert fixture.access.availability_calls == [] + + +@pytest.mark.parametrize("reference", ["code", "id"]) +def test_public_identity_dependency_failure_preserves_its_type(reference: str) -> None: + failure = WebAppAccessUnavailableError("dependency") + fixture = _service(access=AccessQueryStub(error=failure), enabled=False) + with pytest.raises(WebAppAccessUnavailableError) as raised: + fixture.service.resolve_app_id(**({"app_code": "site-code"} if reference == "code" else {"app_id": "app-1"})) + assert raised.value is failure + assert fixture.policy.access_mode_calls == [] + + +@pytest.mark.parametrize("enabled", [True, False]) +@pytest.mark.parametrize("app_id", [None, "resolved-id"]) +def test_nullable_code_lookup_preserves_repository_admission_without_policy_reads( + enabled: bool, app_id: str | None +) -> None: + fixture = _service(access=AccessQueryStub(app_id=app_id), enabled=enabled) + + assert fixture.service.find_app_id_by_code("site-code") == app_id + assert fixture.access.calls == ["site-code"] + assert fixture.access.availability_calls == [] + assert fixture.policy.access_mode_calls == [] + assert fixture.policy.permission_calls == [] + + +def test_nullable_code_lookup_does_not_turn_dependency_failure_into_missing_app() -> None: + failure = WebAppAccessUnavailableError("dependency") + fixture = _service(access=AccessQueryStub(error=failure)) + + with pytest.raises(WebAppAccessUnavailableError) as raised: + fixture.service.find_app_id_by_code("site-code") + + assert raised.value is failure + assert fixture.policy.access_mode_calls == [] + + def test_enabled_auth_reads_access_mode_by_app_id() -> None: fixture = _service() assert fixture.service.get_access_mode(app_id="app-1", app_code=None) is WebAppAccessMode.PRIVATE assert fixture.access.calls == [] + assert fixture.access.availability_calls == ["app-1"] assert fixture.policy.access_mode_calls == ["app-1"] @@ -110,6 +189,7 @@ def test_app_code_takes_precedence_over_app_id() -> None: assert fixture.service.get_access_mode(app_id="ignored-id", app_code="code-1") is WebAppAccessMode.SSO_VERIFIED assert fixture.access.calls == ["code-1"] assert fixture.policy.access_mode_calls == ["resolved-id"] + assert fixture.access.availability_calls == [] def test_missing_app_code_raises_not_found() -> None: @@ -121,6 +201,30 @@ def test_missing_app_code_raises_not_found() -> None: assert fixture.policy.access_mode_calls == [] +@pytest.mark.parametrize("enabled", [True, False]) +def test_supplied_missing_app_id_is_not_public_when_auth_disabled(enabled: bool) -> None: + fixture = _service(access=AccessQueryStub(available=False), enabled=enabled) + with pytest.raises(WebAppAccessAppNotFoundError): + fixture.service.get_access_mode(app_id="missing-app", app_code=None) + assert fixture.access.availability_calls == ["missing-app"] + assert fixture.policy.access_mode_calls == [] + + +def test_disabled_auth_resolves_supplied_missing_code_before_public_shortcut() -> None: + fixture = _service(enabled=False) + with pytest.raises(WebAppAccessAppNotFoundError): + fixture.service.get_access_mode(app_id=None, app_code="missing-code") + assert fixture.access.calls == ["missing-code"] + assert fixture.policy.access_mode_calls == [] + + +def test_disabled_auth_returns_public_for_existing_code_without_enterprise_lookup() -> None: + fixture = _service(access=AccessQueryStub(app_id="resolved-id"), enabled=False) + assert fixture.service.get_access_mode(app_id=None, app_code="existing-code") is WebAppAccessMode.PUBLIC + assert fixture.policy.access_mode_calls == [] + assert fixture.access.availability_calls == [] + + def test_enabled_auth_requires_app_id_or_code() -> None: fixture = _service() @@ -193,6 +297,20 @@ def test_app_code_lookup_releases_database_before_access_policy(sqlite_engine: E factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False, close_resets_only=False) app_id = "11111111-1111-1111-1111-111111111111" with factory.begin() as session: + config = AppModelConfig(app_id=app_id) + config.id = "22222222-2222-2222-2222-222222222222" + session.add(config) + session.add( + App( + id=app_id, + tenant_id=app_id, + name="Test App", + mode="chat", + enable_site=True, + enable_api=True, + app_model_config_id=config.id, + ) + ) session.add( Site( app_id=app_id, @@ -268,6 +386,9 @@ def get_user_permissions(self, *, user_id: str, app_ids: Sequence[str]) -> Mappi def find_app_id_by_code(self, app_code: str) -> str | None: raise AssertionError(f"Batch queries must not resolve app codes: {app_code}") + def is_app_available(self, app_id: str) -> bool: + raise AssertionError(f"Batch queries must not check app availability: {app_id}") + def get_access_mode(self, app_id: str) -> WebAppAccessMode: raise AssertionError(f"Batch queries must not use single-app lookups: {app_id}") diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 35e8c7824900ed..434997871a3094 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -6,16 +6,19 @@ from uuid import uuid4 import pytest +from pytest_mock import MockerFixture from sqlalchemy.orm import Session import tasks.remove_app_and_related_data_task as remove_app_task_module from enums import DeploymentEdition +from extensions.ext_application_services import ApplicationServices from graphon.enums import WorkflowExecutionStatus from libs.archive_storage import ArchiveStorageNotConfiguredError from models import AppStar from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.workflow import WorkflowArchiveLog +from services.network_access_group_service import NetworkAccessGroupUpstreamError from tasks.remove_app_and_related_data_task import ( _delete_app_stars, _delete_app_workflow_archive_logs, @@ -122,6 +125,140 @@ def test_app_cleanup_removes_agent_bindings_before_workflows(monkeypatch: pytest delete_workflows.assert_called_once_with("tenant-1", "app-1") +def test_cloud_app_cleanup_schedules_independent_binding_cleanup( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + apply_async = mocker.patch.object( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + ) + + remove_app_task_module._schedule_app_network_access_group_binding_cleanup("tenant-1", "app-1") + + apply_async.assert_called_once_with( + kwargs={"tenant_id": "tenant-1", "app_id": "app-1"}, + countdown=10, + ) + + +def test_community_app_cleanup_does_not_schedule_binding_cleanup( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + apply_async = mocker.patch.object( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + ) + + remove_app_task_module._schedule_app_network_access_group_binding_cleanup("tenant-1", "app-1") + + apply_async.assert_not_called() + + +def test_independent_binding_cleanup_task_succeeds( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, account_application_services: ApplicationServices +) -> None: + cleanup_binding = mocker.patch.object( + account_application_services.network_access_groups, "cleanup_app_binding", return_value=True + ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + + with patch( + "extensions.ext_application_services.application_services", + return_value=account_application_services, + ): + remove_app_task_module.cleanup_app_network_access_group_binding_task.run( + tenant_id="tenant-1", + app_id="app-1", + ) + + cleanup_binding.assert_called_once_with(workspace_id="tenant-1", app_id="app-1") + assert remove_app_task_module.cleanup_app_network_access_group_binding_task.max_retries == 24 + + +def test_independent_binding_cleanup_task_retries_with_exponential_backoff( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, + account_application_services: ApplicationServices, +) -> None: + upstream_error = NetworkAccessGroupUpstreamError(409, "NETWORK_ACCESS_APP_STILL_EXISTS") + retry_error = RuntimeError("retry scheduled") + mocker.patch.object( + account_application_services.network_access_groups, "cleanup_app_binding", side_effect=upstream_error + ) + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + retry = mocker.patch.object( + remove_app_task_module.cleanup_app_network_access_group_binding_task, "retry", side_effect=retry_error + ) + + with ( + patch( + "extensions.ext_application_services.application_services", + return_value=account_application_services, + ), + pytest.raises(RuntimeError, match="retry scheduled"), + ): + remove_app_task_module.cleanup_app_network_access_group_binding_task.run( + tenant_id="tenant-1", + app_id="app-1", + ) + + retry.assert_called_once_with(exc=upstream_error, countdown=15) + assert remove_app_task_module._network_access_binding_cleanup_retry_delay(5) == 480 + assert remove_app_task_module._network_access_binding_cleanup_retry_delay(6) == 600 + assert remove_app_task_module._network_access_binding_cleanup_retry_delay(24) == 600 + + +def test_parent_local_cleanup_failure_happens_after_binding_cleanup_is_scheduled( + monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, +) -> None: + local_error = RuntimeError("local cleanup failed") + retry_error = RuntimeError("parent retry scheduled") + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + apply_async = mocker.patch.object( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + ) + mocker.patch.object(remove_app_task_module, "_delete_app_model_configs", side_effect=local_error) + retry = mocker.patch.object( + remove_app_task_module.remove_app_and_related_data_task, "retry", side_effect=retry_error + ) + + with pytest.raises(RuntimeError, match="parent retry scheduled"): + remove_app_task_module.remove_app_and_related_data_task.run(tenant_id="tenant-1", app_id="app-1") + + apply_async.assert_called_once_with( + kwargs={"tenant_id": "tenant-1", "app_id": "app-1"}, + countdown=10, + ) + retry.assert_called_once_with(exc=local_error, countdown=60) + + +def test_parent_retries_before_local_cleanup_when_binding_schedule_fails( + monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + broker_error = RuntimeError("broker unavailable") + retry_error = RuntimeError("parent retry scheduled") + apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) + mocker.patch.object( + remove_app_task_module.cleanup_app_network_access_group_binding_task, + "apply_async", + side_effect=broker_error, + ) + first_local_cleanup = mocker.patch.object(remove_app_task_module, "_delete_app_model_configs") + retry = mocker.patch.object( + remove_app_task_module.remove_app_and_related_data_task, "retry", side_effect=retry_error + ) + + with pytest.raises(RuntimeError, match="parent retry scheduled"): + remove_app_task_module.remove_app_and_related_data_task.run(tenant_id="tenant-1", app_id="app-1") + + retry.assert_called_once_with(exc=broker_error, countdown=60) + first_local_cleanup.assert_not_called() + + class TestDeleteDraftVariablesBatch: def test_delete_draft_variables_batch_invalid_batch_size(self): """Test that invalid batch size raises ValueError.""" diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example index f7272e50dd7bf2..864930f7dc94a6 100644 --- a/docker/envs/core-services/api.env.example +++ b/docker/envs/core-services/api.env.example @@ -3,6 +3,11 @@ # ------------------------------ MODE=api +# Optional SaaS network-policy control plane and current-IP preview trust. +NETWORK_ACCESS_API_URL= +# Comma-separated proxy CIDRs; empty disables Cloud IP previews. The edge +# must sanitize/append XFF and the trust boundary must match Gateway. +NETWORK_ACCESS_TRUSTED_PROXY_CIDRS= SENTRY_DSN= SENTRY_TRACES_SAMPLE_RATE=1.0 SENTRY_PROFILES_SAMPLE_RATE=1.0 diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example index 9ac8634d407d9d..27b855ac19c097 100644 --- a/docker/envs/core-services/web.env.example +++ b/docker/envs/core-services/web.env.example @@ -4,6 +4,14 @@ CONSOLE_API_URL= APP_API_URL= +# Optional Cloud WebApp document admission. Enable only after the edge proxy +# overwrites both X-Dify-Webapp-Client-Ip and X-Dify-Webapp-Proxy-Secret, Web is +# private to that proxy, and the target Gateway enforces /api using trusted IPs. +# Use the complete internal Gateway endpoint, never a direct Core API URL. +# Example: http://gateway-public:8080/api/webapp/access-mode +WEBAPP_ACCESS_PREFLIGHT_URL= +# Provision outside Git; must match the edge-injected secret. Never NEXT_PUBLIC. +WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET= SENTRY_DSN= NEXT_PUBLIC_SOCKET_URL=ws://localhost EXPERIMENTAL_ENABLE_VINEXT=false diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts index d4532f3c9b9de3..7a7b8a100ff955 100644 --- a/e2e/features/step-definitions/apps/web-app-service.steps.ts +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -104,8 +104,16 @@ Then('the published workflow Web App should be accessible', async function (this Then('the published workflow Web App should be unavailable', async function (this: DifyWorld) { if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') - await expect(this.sharedAppPage.getByRole('heading', { name: '404' })).toBeVisible({ + await expect( + this.sharedAppPage.getByRole('heading', { + name: 'This app doesn’t exist or isn’t accessible from your network.', + }), + ).toBeVisible({ timeout: 15_000, }) - await expect(this.sharedAppPage.getByText('App is unavailable', { exact: true })).toBeVisible() + await expect( + this.sharedAppPage.getByText('Contact the app administrator if you need help.', { + exact: true, + }), + ).toBeVisible() }) diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index ac7800e0fa4a6e..a45db91b8749c0 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -167,6 +167,10 @@ import { zGetAppsByAppIdExportResponse, zGetAppsByAppIdMessagesByMessageIdPath, zGetAppsByAppIdMessagesByMessageIdResponse, + zGetAppsByAppIdNetworkAccessGroupPath, + zGetAppsByAppIdNetworkAccessGroupResponse, + zGetAppsByAppIdNetworkAccessGroupStatusPath, + zGetAppsByAppIdNetworkAccessGroupStatusResponse, zGetAppsByAppIdPath, zGetAppsByAppIdResponse, zGetAppsByAppIdServerPath, @@ -491,6 +495,9 @@ import { zPostAppsWorkflowsOnlineUsersBody, zPostAppsWorkflowsOnlineUsersResponse, zPutAppsByAppIdBody, + zPutAppsByAppIdNetworkAccessGroupBody, + zPutAppsByAppIdNetworkAccessGroupPath, + zPutAppsByAppIdNetworkAccessGroupResponse, zPutAppsByAppIdPath, zPutAppsByAppIdResponse, zPutAppsByAppIdServerBody, @@ -2132,6 +2139,57 @@ export const name = { post: post26, } +export const get33 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdNetworkAccessGroupStatus', + path: '/apps/{app_id}/network-access-group/status', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdNetworkAccessGroupStatusPath })) + .output(zGetAppsByAppIdNetworkAccessGroupStatusResponse) + .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) + +export const status2 = { + get: get33, +} + +export const get34 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdNetworkAccessGroup', + path: '/apps/{app_id}/network-access-group', + tags: ['console'], + }) + .input(z.object({ params: zGetAppsByAppIdNetworkAccessGroupPath })) + .output(zGetAppsByAppIdNetworkAccessGroupResponse) + .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putAppsByAppIdNetworkAccessGroup', + path: '/apps/{app_id}/network-access-group', + tags: ['console'], + }) + .input( + z.object({ + body: zPutAppsByAppIdNetworkAccessGroupBody, + params: zPutAppsByAppIdNetworkAccessGroupPath, + }), + ) + .output(zPutAppsByAppIdNetworkAccessGroupResponse) + .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) + +export const networkAccessGroup = { + get: get34, + put, + status: status2, +} + export const post27 = oc .route({ inputStructure: 'detailed', @@ -2170,7 +2228,7 @@ export const refresh = { /** * Get MCP server configuration for an application */ -export const get33 = oc +export const get35 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -2203,7 +2261,7 @@ export const post29 = oc /** * Update MCP server configuration for an application */ -export const put = oc +export const put2 = oc .route({ description: 'Update MCP server configuration for an application', inputStructure: 'detailed', @@ -2217,9 +2275,9 @@ export const put = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const server = { - get: get33, + get: get35, post: post29, - put, + put: put2, refresh, } @@ -2321,7 +2379,7 @@ export const star = { /** * Get average response time statistics for an application */ -export const get34 = oc +export const get36 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2340,13 +2398,13 @@ export const get34 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const averageResponseTime = { - get: get34, + get: get36, } /** * Get average session interaction statistics for an application */ -export const get35 = oc +export const get37 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2365,13 +2423,13 @@ export const get35 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const averageSessionInteractions = { - get: get35, + get: get37, } /** * Get daily conversation statistics for an application */ -export const get36 = oc +export const get38 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2390,13 +2448,13 @@ export const get36 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const dailyConversations = { - get: get36, + get: get38, } /** * Get daily terminal/end-user statistics for an application */ -export const get37 = oc +export const get39 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2415,13 +2473,13 @@ export const get37 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const dailyEndUsers = { - get: get37, + get: get39, } /** * Get daily message statistics for an application */ -export const get38 = oc +export const get40 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2440,13 +2498,13 @@ export const get38 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const dailyMessages = { - get: get38, + get: get40, } /** * Get daily token cost statistics for an application */ -export const get39 = oc +export const get41 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2465,13 +2523,13 @@ export const get39 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const tokenCosts = { - get: get39, + get: get41, } /** * Get tokens per second statistics for an application */ -export const get40 = oc +export const get42 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2490,13 +2548,13 @@ export const get40 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const tokensPerSecond = { - get: get40, + get: get42, } /** * Get user satisfaction rate statistics for an application */ -export const get41 = oc +export const get43 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2515,7 +2573,7 @@ export const get41 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const userSatisfactionRate = { - get: get41, + get: get43, } export const statistics = { @@ -2532,7 +2590,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get42 = oc +export const get44 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2557,7 +2615,7 @@ export const get42 = oc }) export const voices = { - get: get42, + get: get44, } /** @@ -2597,7 +2655,7 @@ export const textToAudio = { /** * Get app tracing configuration */ -export const get43 = oc +export const get45 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2626,7 +2684,7 @@ export const post35 = oc .errors({ tracing_provider_unavailable: { status: 400, data: zTracingProviderUnavailableError } }) export const trace = { - get: get43, + get: get45, post: post35, } @@ -2663,7 +2721,7 @@ export const delete8 = oc /** * Get tracing configuration for an application */ -export const get44 = oc +export const get46 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2750,7 +2808,7 @@ export const post36 = oc export const traceConfig = { delete: delete8, - get: get44, + get: get46, patch, post: post36, } @@ -2783,7 +2841,7 @@ export const triggerEnable = { /** * Get app triggers list */ -export const get45 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2797,7 +2855,7 @@ export const get45 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const triggers = { - get: get45, + get: get47, } /** @@ -2805,7 +2863,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get46 = oc +export const get48 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2825,7 +2883,7 @@ export const get46 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const workflowAppLogs = { - get: get46, + get: get48, } /** @@ -2833,7 +2891,7 @@ export const workflowAppLogs = { * * Get workflow runs count statistics */ -export const get47 = oc +export const get49 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2853,7 +2911,7 @@ export const get47 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const count3 = { - get: get47, + get: get49, } /** @@ -2892,7 +2950,7 @@ export const tasks = { * * Get workflow run node execution list */ -export const get48 = oc +export const get50 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2907,7 +2965,7 @@ export const get48 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const nodeExecutions = { - get: get48, + get: get50, } /** @@ -2915,7 +2973,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get49 = oc +export const get51 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2930,7 +2988,7 @@ export const get49 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const byRunId = { - get: get49, + get: get51, nodeExecutions, } @@ -2969,7 +3027,7 @@ export const download4 = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get50 = oc +export const get52 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -2993,13 +3051,13 @@ export const get50 = oc }) export const read = { - get: get50, + get: get52, } /** * List a directory in a workflow Agent node sandbox */ -export const get51 = oc +export const get53 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -3023,7 +3081,7 @@ export const get51 = oc }) export const files3 = { - get: get51, + get: get53, download: download4, read, } @@ -3049,7 +3107,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get52 = oc +export const get54 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -3069,7 +3127,7 @@ export const get52 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const workflowRuns2 = { - get: get52, + get: get54, count: count3, tasks, byRunId, @@ -3081,7 +3139,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get53 = oc +export const get55 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -3099,7 +3157,7 @@ export const get53 = oc }) export const mentionUsers = { - get: get53, + get: get55, } /** @@ -3130,7 +3188,7 @@ export const delete9 = oc * * Update a comment reply */ -export const put2 = oc +export const put3 = oc .route({ description: 'Update a comment reply', inputStructure: 'detailed', @@ -3154,7 +3212,7 @@ export const put2 = oc export const byReplyId = { delete: delete9, - put: put2, + put: put3, } /** @@ -3244,7 +3302,7 @@ export const delete10 = oc * * Get a specific workflow comment */ -export const get54 = oc +export const get56 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3266,7 +3324,7 @@ export const get54 = oc * * Update a workflow comment */ -export const put3 = oc +export const put4 = oc .route({ description: 'Update a workflow comment', inputStructure: 'detailed', @@ -3290,8 +3348,8 @@ export const put3 = oc export const byCommentId = { delete: delete10, - get: get54, - put: put3, + get: get56, + put: put4, replies, resolve, } @@ -3301,7 +3359,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get55 = oc +export const get57 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3347,7 +3405,7 @@ export const post42 = oc }) export const comments = { - get: get55, + get: get57, post: post42, mentionUsers, byCommentId, @@ -3356,7 +3414,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get56 = oc +export const get58 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -3375,13 +3433,13 @@ export const get56 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const averageAppInteractions = { - get: get56, + get: get58, } /** * Get workflow daily runs statistics */ -export const get57 = oc +export const get59 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3400,13 +3458,13 @@ export const get57 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const dailyConversations2 = { - get: get57, + get: get59, } /** * Get workflow daily terminals statistics */ -export const get58 = oc +export const get60 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3425,13 +3483,13 @@ export const get58 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const dailyTerminals = { - get: get58, + get: get60, } /** * Get workflow daily token cost statistics */ -export const get59 = oc +export const get61 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3450,7 +3508,7 @@ export const get59 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const tokenCosts2 = { - get: get59, + get: get61, } export const statistics2 = { @@ -3470,7 +3528,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get60 = oc +export const get62 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3490,7 +3548,7 @@ export const get60 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const byBlockType = { - get: get60, + get: get62, } /** @@ -3498,7 +3556,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get61 = oc +export const get63 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3513,14 +3571,14 @@ export const get61 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const defaultWorkflowBlockConfigs = { - get: get61, + get: get63, byBlockType, } /** * Get conversation variables for workflow */ -export const get62 = oc +export const get64 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3555,7 +3613,7 @@ export const post43 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const conversationVariables2 = { - get: get62, + get: get64, post: post43, } @@ -3564,7 +3622,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get63 = oc +export const get65 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3603,7 +3661,7 @@ export const post44 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const environmentVariables = { - get: get63, + get: get65, post: post44, } @@ -3814,7 +3872,7 @@ export const loop2 = { nodes: nodes6, } -export const get64 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3829,7 +3887,7 @@ export const get64 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const candidates = { - get: get64, + get: get66, } export const post51 = oc @@ -3935,7 +3993,7 @@ export const validate = { post: post54, } -export const get65 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3956,7 +4014,7 @@ export const get65 = oc app_not_found: { status: 404, data: zAppNotFoundError }, }) -export const put4 = oc +export const put5 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3982,8 +4040,8 @@ export const put4 = oc }) export const agentComposer = { - get: get65, - put: put4, + get: get67, + put: put5, candidates, copyFromRoster, impact, @@ -3994,7 +4052,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get66 = oc +export const get68 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -4008,7 +4066,7 @@ export const get66 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const lastRun = { - get: get66, + get: get68, } /** @@ -4086,7 +4144,7 @@ export const delete11 = oc /** * Get variables for a specific node */ -export const get67 = oc +export const get69 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -4101,7 +4159,7 @@ export const get67 = oc export const variables = { delete: delete11, - get: get67, + get: get69, } export const byNodeId8 = { @@ -4150,7 +4208,7 @@ export const run10 = { /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get68 = oc +export const get70 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -4167,13 +4225,13 @@ export const get68 = oc }) export const events = { - get: get68, + get: get70, } /** * Full value for one declared output, including signed download URL for files. */ -export const get69 = oc +export const get71 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -4196,7 +4254,7 @@ export const get69 = oc }) export const preview5 = { - get: get69, + get: get71, } export const byOutputName = { @@ -4206,7 +4264,7 @@ export const byOutputName = { /** * One node's declared outputs for a draft workflow run. */ -export const get70 = oc +export const get72 = oc .route({ description: "One node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -4224,14 +4282,14 @@ export const get70 = oc }) export const byNodeId9 = { - get: get70, + get: get72, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get71 = oc +export const get73 = oc .route({ description: "Snapshot of every node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -4248,7 +4306,7 @@ export const get71 = oc }) export const nodeOutputs = { - get: get71, + get: get73, events, byNodeId: byNodeId9, } @@ -4264,7 +4322,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get72 = oc +export const get74 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -4278,7 +4336,7 @@ export const get72 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const systemVariables = { - get: get72, + get: get74, } /** @@ -4351,7 +4409,7 @@ export const trigger2 = { /** * Reset a workflow variable to its default value */ -export const put5 = oc +export const put6 = oc .route({ description: 'Reset a workflow variable to its default value', inputStructure: 'detailed', @@ -4365,7 +4423,7 @@ export const put5 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const reset = { - put: put5, + put: put6, } /** @@ -4388,7 +4446,7 @@ export const delete12 = oc /** * Get a specific workflow variable */ -export const get73 = oc +export const get75 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -4424,7 +4482,7 @@ export const patch2 = oc export const byVariableId = { delete: delete12, - get: get73, + get: get75, patch: patch2, reset, } @@ -4451,7 +4509,7 @@ export const delete13 = oc * * Get draft workflow variables */ -export const get74 = oc +export const get76 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4475,7 +4533,7 @@ export const get74 = oc export const variables2 = { delete: delete13, - get: get74, + get: get76, byVariableId, } @@ -4484,7 +4542,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get75 = oc +export const get77 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4529,7 +4587,7 @@ export const post60 = oc }) export const draft2 = { - get: get75, + get: get77, post: post60, conversationVariables: conversationVariables2, environmentVariables, @@ -4550,7 +4608,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get76 = oc +export const get78 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4586,14 +4644,14 @@ export const post61 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const publish = { - get: get76, + get: get78, post: post61, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get77 = oc +export const get79 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4610,13 +4668,13 @@ export const get77 = oc }) export const events2 = { - get: get77, + get: get79, } /** * Full value for one declared output of a published run. */ -export const get78 = oc +export const get80 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4643,7 +4701,7 @@ export const get78 = oc }) export const preview6 = { - get: get78, + get: get80, } export const byOutputName2 = { @@ -4653,7 +4711,7 @@ export const byOutputName2 = { /** * One node's declared outputs for a published workflow run. */ -export const get79 = oc +export const get81 = oc .route({ description: "One node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4671,14 +4729,14 @@ export const get79 = oc }) export const byNodeId10 = { - get: get79, + get: get81, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get80 = oc +export const get82 = oc .route({ description: "Snapshot of every node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4695,7 +4753,7 @@ export const get80 = oc }) export const nodeOutputs2 = { - get: get80, + get: get82, events: events2, byNodeId: byNodeId10, } @@ -4715,7 +4773,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get81 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4734,7 +4792,7 @@ export const get81 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const webhook = { - get: get81, + get: get83, } export const triggers2 = { @@ -4813,7 +4871,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get82 = oc +export const get84 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4833,7 +4891,7 @@ export const get82 = oc .errors({ app_not_found: { status: 404, data: zAppNotFoundError } }) export const workflows3 = { - get: get82, + get: get84, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4861,7 +4919,7 @@ export const delete15 = oc /** * Get application details */ -export const get83 = oc +export const get85 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4876,7 +4934,7 @@ export const get83 = oc /** * Update application details */ -export const put6 = oc +export const put7 = oc .route({ description: 'Update application details', inputStructure: 'detailed', @@ -4890,8 +4948,8 @@ export const put6 = oc export const byAppId2 = { delete: delete15, - get: get83, - put: put6, + get: get85, + put: put7, advancedChat, agent, annotationReply, @@ -4913,6 +4971,7 @@ export const byAppId2 = { messages, modelConfig, name, + networkAccessGroup, publishToCreatorsPlatform, server, site, @@ -4953,7 +5012,7 @@ export const byApiKeyId = { /** * Get all API keys for an app */ -export const get84 = oc +export const get86 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4986,7 +5045,7 @@ export const post63 = oc }) export const apiKeys = { - get: get84, + get: get86, post: post63, byApiKeyId, } @@ -4998,7 +5057,7 @@ export const byResourceId = { /** * Get list of applications with pagination and filtering */ -export const get85 = oc +export const get87 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -5027,7 +5086,7 @@ export const post64 = oc .output(zPostAppsResponse) export const apps = { - get: get85, + get: get87, post: post64, imports, recent, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index cdd814d87271ea..53b6d3f4781e05 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -699,6 +699,36 @@ export type AppNamePayload = { name: string } +export type AppNetworkAccessGroupResponse = { + app_id: string + available_access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> + binding: AppNetworkAccessGroupBindingResponse | null + effective_enabled: boolean + entitled: boolean + tenant_id: string +} + +export type AppNetworkAccessGroupUpdatePayload = { + access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> + enabled: boolean + expected_version: number + group_id: string | null +} + +export type AppNetworkAccessGroupMutationResponse = { + available_access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> + binding: AppNetworkAccessGroupBindingResponse + effective_enabled: boolean +} + +export type AppNetworkAccessGroupStatusResponse = { + available_count: number + configured: boolean + covered_count: number + enabled: boolean + entitled: boolean +} + export type RedirectUrlResponse = { redirect_url: string } @@ -2076,6 +2106,19 @@ export type AppUserInputFormPayload = | AppCheckboxInputPayload | AppExternalDataInputPayload +export type AppNetworkAccessGroupBindingResponse = { + access_points: Array<'mcp' | 'service_api' | 'trigger' | 'webapp'> + app_id: string + created_at: string + enabled: boolean + group_id?: string | null + id: string + tenant_id: string + updated_at: string + updated_by_account_id?: string | null + version: number +} + export type AppMcpServerStatus = 'active' | 'inactive' | 'normal' export type AverageResponseTimeStatisticItem = { @@ -6334,6 +6377,78 @@ export type PostAppsByAppIdNameResponses = { export type PostAppsByAppIdNameResponse = PostAppsByAppIdNameResponses[keyof PostAppsByAppIdNameResponses] +export type GetAppsByAppIdNetworkAccessGroupData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/network-access-group' +} + +export type GetAppsByAppIdNetworkAccessGroupErrors = { + 404: AppNotFoundError | ErrorResponse + default: ErrorResponse +} + +export type GetAppsByAppIdNetworkAccessGroupError = + GetAppsByAppIdNetworkAccessGroupErrors[keyof GetAppsByAppIdNetworkAccessGroupErrors] + +export type GetAppsByAppIdNetworkAccessGroupResponses = { + 200: AppNetworkAccessGroupResponse +} + +export type GetAppsByAppIdNetworkAccessGroupResponse = + GetAppsByAppIdNetworkAccessGroupResponses[keyof GetAppsByAppIdNetworkAccessGroupResponses] + +export type PutAppsByAppIdNetworkAccessGroupData = { + body: AppNetworkAccessGroupUpdatePayload + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/network-access-group' +} + +export type PutAppsByAppIdNetworkAccessGroupErrors = { + 404: AppNotFoundError | ErrorResponse + default: ErrorResponse +} + +export type PutAppsByAppIdNetworkAccessGroupError = + PutAppsByAppIdNetworkAccessGroupErrors[keyof PutAppsByAppIdNetworkAccessGroupErrors] + +export type PutAppsByAppIdNetworkAccessGroupResponses = { + 200: AppNetworkAccessGroupMutationResponse +} + +export type PutAppsByAppIdNetworkAccessGroupResponse = + PutAppsByAppIdNetworkAccessGroupResponses[keyof PutAppsByAppIdNetworkAccessGroupResponses] + +export type GetAppsByAppIdNetworkAccessGroupStatusData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/network-access-group/status' +} + +export type GetAppsByAppIdNetworkAccessGroupStatusErrors = { + 404: AppNotFoundError | ErrorResponse + default: ErrorResponse +} + +export type GetAppsByAppIdNetworkAccessGroupStatusError = + GetAppsByAppIdNetworkAccessGroupStatusErrors[keyof GetAppsByAppIdNetworkAccessGroupStatusErrors] + +export type GetAppsByAppIdNetworkAccessGroupStatusResponses = { + 200: AppNetworkAccessGroupStatusResponse +} + +export type GetAppsByAppIdNetworkAccessGroupStatusResponse = + GetAppsByAppIdNetworkAccessGroupStatusResponses[keyof GetAppsByAppIdNetworkAccessGroupStatusResponses] + export type PostAppsByAppIdPublishToCreatorsPlatformData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index c3831f63ca7cc3..b2ae22ef113db9 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -642,6 +642,27 @@ export const zAppNamePayload = z.object({ name: z.string().min(1), }) +/** + * AppNetworkAccessGroupUpdatePayload + */ +export const zAppNetworkAccessGroupUpdatePayload = z.object({ + access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])).max(4), + enabled: z.boolean(), + expected_version: z.int().gte(0), + group_id: z.uuid().nullable(), +}) + +/** + * AppNetworkAccessGroupStatusResponse + */ +export const zAppNetworkAccessGroupStatusResponse = z.object({ + available_count: z.int().gte(0), + configured: z.boolean(), + covered_count: z.int().gte(0), + enabled: z.boolean(), + entitled: z.boolean(), +}) + /** * RedirectUrlResponse */ @@ -1805,6 +1826,43 @@ export const zMessageFile = z.object({ url: z.string().nullish(), }) +/** + * AppNetworkAccessGroupBindingResponse + */ +export const zAppNetworkAccessGroupBindingResponse = z.object({ + access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])).max(4), + app_id: z.string(), + created_at: z.iso.datetime(), + enabled: z.boolean(), + group_id: z.string().nullish(), + id: z.string(), + tenant_id: z.string(), + updated_at: z.iso.datetime(), + updated_by_account_id: z.string().nullish(), + version: z.int().gte(1), +}) + +/** + * AppNetworkAccessGroupResponse + */ +export const zAppNetworkAccessGroupResponse = z.object({ + app_id: z.string(), + available_access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])), + binding: zAppNetworkAccessGroupBindingResponse.nullable(), + effective_enabled: z.boolean(), + entitled: z.boolean(), + tenant_id: z.string(), +}) + +/** + * AppNetworkAccessGroupMutationResponse + */ +export const zAppNetworkAccessGroupMutationResponse = z.object({ + available_access_points: z.array(z.enum(['mcp', 'service_api', 'trigger', 'webapp'])), + binding: zAppNetworkAccessGroupBindingResponse, + effective_enabled: z.boolean(), +}) + /** * AppMCPServerStatus * @@ -6928,6 +6986,35 @@ export const zPostAppsByAppIdNamePath = z.object({ */ export const zPostAppsByAppIdNameResponse = zAppDetail +export const zGetAppsByAppIdNetworkAccessGroupPath = z.object({ + app_id: z.uuid(), +}) + +/** + * App network access group binding retrieved successfully + */ +export const zGetAppsByAppIdNetworkAccessGroupResponse = zAppNetworkAccessGroupResponse + +export const zPutAppsByAppIdNetworkAccessGroupBody = zAppNetworkAccessGroupUpdatePayload + +export const zPutAppsByAppIdNetworkAccessGroupPath = z.object({ + app_id: z.uuid(), +}) + +/** + * App network access group binding updated successfully + */ +export const zPutAppsByAppIdNetworkAccessGroupResponse = zAppNetworkAccessGroupMutationResponse + +export const zGetAppsByAppIdNetworkAccessGroupStatusPath = z.object({ + app_id: z.uuid(), +}) + +/** + * App network access control entry status retrieved successfully + */ +export const zGetAppsByAppIdNetworkAccessGroupStatusResponse = zAppNetworkAccessGroupStatusResponse + export const zPostAppsByAppIdPublishToCreatorsPlatformPath = z.object({ app_id: z.uuid(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 5f60fcb6548d4a..a59afcf423f561 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -46,6 +46,9 @@ import { zDeleteWorkspacesCurrentModelProvidersByProviderModelsPath, zDeleteWorkspacesCurrentModelProvidersByProviderModelsQuery, zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse, + zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdQuery, + zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, zDeleteWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdMemberBindingsBody, @@ -114,6 +117,12 @@ import { zGetWorkspacesCurrentModelProvidersSummaryResponse, zGetWorkspacesCurrentModelsModelTypesByModelTypePath, zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, + zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse, + zGetWorkspacesCurrentNetworkAccessGroupsResponse, zGetWorkspacesCurrentPermissionResponse, zGetWorkspacesCurrentPluginAssetQuery, zGetWorkspacesCurrentPluginAssetResponse, @@ -376,6 +385,8 @@ import { zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeBody, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypePath, zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse, + zPostWorkspacesCurrentNetworkAccessGroupsBody, + zPostWorkspacesCurrentNetworkAccessGroupsResponse, zPostWorkspacesCurrentPluginAutoUpgradeChangeBody, zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse, zPostWorkspacesCurrentPluginAutoUpgradeExcludeBody, @@ -518,6 +529,9 @@ import { zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, + zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdBody, + zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdBody, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, @@ -1802,6 +1816,114 @@ export const models2 = { } export const get22 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessGroupsCurrentIp', + path: '/workspaces/current/network-access-groups/current-ip', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse) + +export const currentIp = { + get: get22, +} + +export const get23 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIp', + path: '/workspaces/current/network-access-groups/{group_id}/check-current-ip', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath })) + .output(zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse) + +export const checkCurrentIp = { + get: get23, +} + +export const delete8 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentNetworkAccessGroupsByGroupId', + path: '/workspaces/current/network-access-groups/{group_id}', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + query: zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdQuery, + }), + ) + .output(zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) + +export const get24 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessGroupsByGroupId', + path: '/workspaces/current/network-access-groups/{group_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath })) + .output(zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) + +export const put5 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentNetworkAccessGroupsByGroupId', + path: '/workspaces/current/network-access-groups/{group_id}', + tags: ['console'], + }) + .input( + z.object({ + body: zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdBody, + params: zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath, + }), + ) + .output(zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse) + +export const byGroupId = { + delete: delete8, + get: get24, + put: put5, + checkCurrentIp, +} + +export const get25 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentNetworkAccessGroups', + path: '/workspaces/current/network-access-groups', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentNetworkAccessGroupsResponse) + +export const post26 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentNetworkAccessGroups', + path: '/workspaces/current/network-access-groups', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentNetworkAccessGroupsBody })) + .output(zPostWorkspacesCurrentNetworkAccessGroupsResponse) + +export const networkAccessGroups = { + get: get25, + post: post26, + currentIp, + byGroupId, +} + +export const get26 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1812,10 +1934,10 @@ export const get22 = oc .output(zGetWorkspacesCurrentPermissionResponse) export const permission = { - get: get22, + get: get26, } -export const get23 = oc +export const get27 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1828,10 +1950,10 @@ export const get23 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const asset = { - get: get23, + get: get27, } -export const post26 = oc +export const post27 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1843,10 +1965,10 @@ export const post26 = oc .output(zPostWorkspacesCurrentPluginAutoUpgradeChangeResponse) export const change = { - post: post26, + post: post27, } -export const post27 = oc +export const post28 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1858,10 +1980,10 @@ export const post27 = oc .output(zPostWorkspacesCurrentPluginAutoUpgradeExcludeResponse) export const exclude = { - post: post27, + post: post28, } -export const get24 = oc +export const get28 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1873,7 +1995,7 @@ export const get24 = oc .output(zGetWorkspacesCurrentPluginAutoUpgradeFetchResponse) export const fetch_ = { - get: get24, + get: get28, } export const autoUpgrade = { @@ -1882,7 +2004,7 @@ export const autoUpgrade = { fetch: fetch_, } -export const get25 = oc +export const get29 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1894,10 +2016,10 @@ export const get25 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const debuggingKey = { - get: get25, + get: get29, } -export const get26 = oc +export const get30 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1910,10 +2032,10 @@ export const get26 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const fetchManifest = { - get: get26, + get: get30, } -export const get27 = oc +export const get31 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1926,10 +2048,10 @@ export const get27 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const icon = { - get: get27, + get: get31, } -export const post28 = oc +export const post29 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1942,10 +2064,10 @@ export const post28 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const github = { - post: post28, + post: post29, } -export const post29 = oc +export const post30 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1958,10 +2080,10 @@ export const post29 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const marketplace = { - post: post29, + post: post30, } -export const post30 = oc +export const post31 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1974,7 +2096,7 @@ export const post30 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const pkg = { - post: post30, + post: post31, } export const install = { @@ -1983,7 +2105,7 @@ export const install = { pkg, } -export const get28 = oc +export const get32 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1996,10 +2118,10 @@ export const get28 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const installedIds = { - get: get28, + get: get32, } -export const post31 = oc +export const post32 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2012,14 +2134,14 @@ export const post31 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const ids = { - post: post31, + post: post32, } export const installations = { ids, } -export const post32 = oc +export const post33 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2032,10 +2154,10 @@ export const post32 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const latestVersions = { - post: post32, + post: post33, } -export const get29 = oc +export const get33 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2048,12 +2170,12 @@ export const get29 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const list2 = { - get: get29, + get: get33, installations, latestVersions, } -export const get30 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2066,14 +2188,14 @@ export const get30 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const pkg2 = { - get: get30, + get: get34, } export const marketplace2 = { pkg: pkg2, } -export const get31 = oc +export const get35 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2086,13 +2208,13 @@ export const get31 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const dynamicOptions = { - get: get31, + get: get35, } /** * Fetch dynamic options using credentials directly (for edit mode) */ -export const post33 = oc +export const post34 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2108,7 +2230,7 @@ export const post33 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const dynamicOptionsWithCredentials = { - post: post33, + post: post34, } export const parameters = { @@ -2116,7 +2238,7 @@ export const parameters = { dynamicOptionsWithCredentials, } -export const post34 = oc +export const post35 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2128,10 +2250,10 @@ export const post34 = oc .output(zPostWorkspacesCurrentPluginPermissionChangeResponse) export const change2 = { - post: post34, + post: post35, } -export const get32 = oc +export const get36 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2142,7 +2264,7 @@ export const get32 = oc .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) export const fetch2 = { - get: get32, + get: get36, } export const permission2 = { @@ -2150,7 +2272,7 @@ export const permission2 = { fetch: fetch2, } -export const get33 = oc +export const get37 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2162,10 +2284,10 @@ export const get33 = oc .output(zGetWorkspacesCurrentPluginReadmeResponse) export const readme = { - get: get33, + get: get37, } -export const post35 = oc +export const post36 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2177,10 +2299,10 @@ export const post35 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const deleteAll = { - post: post35, + post: post36, } -export const post36 = oc +export const post37 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2193,10 +2315,10 @@ export const post36 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const byIdentifier = { - post: post36, + post: post37, } -export const post37 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2208,12 +2330,12 @@ export const post37 = oc .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse) .errors({ plugin_error: { status: 400, data: zPluginError } }) -export const delete8 = { - post: post37, +export const delete9 = { + post: post38, byIdentifier, } -export const get34 = oc +export const get38 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2226,11 +2348,11 @@ export const get34 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const byTaskId = { - get: get34, - delete: delete8, + get: get38, + delete: delete9, } -export const get35 = oc +export const get39 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2243,12 +2365,12 @@ export const get35 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const tasks = { - get: get35, + get: get39, deleteAll, byTaskId, } -export const post38 = oc +export const post39 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2261,10 +2383,10 @@ export const post38 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const uninstall = { - post: post38, + post: post39, } -export const post39 = oc +export const post40 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2277,10 +2399,10 @@ export const post39 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const github2 = { - post: post39, + post: post40, } -export const post40 = oc +export const post41 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2293,7 +2415,7 @@ export const post40 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const marketplace3 = { - post: post40, + post: post41, } export const upgrade = { @@ -2301,7 +2423,7 @@ export const upgrade = { marketplace: marketplace3, } -export const post41 = oc +export const post42 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2312,10 +2434,10 @@ export const post41 = oc .output(zPostWorkspacesCurrentPluginUploadBundleResponse) export const bundle = { - post: post41, + post: post42, } -export const post42 = oc +export const post43 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2328,10 +2450,10 @@ export const post42 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const github3 = { - post: post42, + post: post43, } -export const post43 = oc +export const post44 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2344,7 +2466,7 @@ export const post43 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const pkg3 = { - post: post43, + post: post44, } export const upload = { @@ -2353,7 +2475,7 @@ export const upload = { pkg: pkg3, } -export const get36 = oc +export const get40 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2371,7 +2493,7 @@ export const get36 = oc .errors({ plugin_error: { status: 400, data: zPluginError } }) export const list3 = { - get: get36, + get: get40, } export const byCategory = { @@ -2398,7 +2520,7 @@ export const plugin2 = { byCategory, } -export const post44 = oc +export const post45 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2411,10 +2533,10 @@ export const post44 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse) export const copy = { - post: post44, + post: post45, } -export const delete9 = oc +export const delete10 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2425,7 +2547,7 @@ export const delete9 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const get37 = oc +export const get41 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2436,7 +2558,7 @@ export const get37 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) .output(zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) -export const put5 = oc +export const put6 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2453,13 +2575,13 @@ export const put5 = oc .output(zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) export const byPolicyId = { - delete: delete9, - get: get37, - put: put5, + delete: delete10, + get: get41, + put: put6, copy, } -export const get38 = oc +export const get42 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2469,7 +2591,7 @@ export const get38 = oc }) .output(zGetWorkspacesCurrentRbacAccessPoliciesResponse) -export const post45 = oc +export const post46 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2482,12 +2604,12 @@ export const post45 = oc .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) export const accessPolicies = { - get: get38, - post: post45, + get: get42, + post: post46, byPolicyId, } -export const put6 = oc +export const put7 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2499,10 +2621,10 @@ export const put6 = oc .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse) export const lock = { - put: put6, + put: put7, } -export const put7 = oc +export const put8 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2514,7 +2636,7 @@ export const put7 = oc .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse) export const unlock = { - put: put7, + put: put8, } export const byBindingId = { @@ -2526,7 +2648,7 @@ export const accessPolicyBindings = { byBindingId, } -export const delete10 = oc +export const delete11 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2542,7 +2664,7 @@ export const delete10 = oc ) .output(zDeleteWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get39 = oc +export const get43 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2558,11 +2680,11 @@ export const get39 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings = { - delete: delete10, - get: get39, + delete: delete11, + get: get43, } -export const get40 = oc +export const get44 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2578,7 +2700,7 @@ export const get40 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings = { - get: get40, + get: get44, } export const byPolicyId2 = { @@ -2590,7 +2712,7 @@ export const accessPolicies2 = { byPolicyId: byPolicyId2, } -export const get41 = oc +export const get45 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2607,10 +2729,10 @@ export const get41 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdAccessPolicyResponse) export const accessPolicy = { - get: get41, + get: get45, } -export const get42 = oc +export const get46 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2627,10 +2749,10 @@ export const get42 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdUserAccessPoliciesResponse) export const userAccessPolicies = { - get: get42, + get: get46, } -export const put8 = oc +export const put9 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2647,7 +2769,7 @@ export const put8 = oc .output(zPutWorkspacesCurrentRbacAgentsByAgentIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies3 = { - put: put8, + put: put9, } export const byTargetAccountId = { @@ -2658,7 +2780,7 @@ export const users = { byTargetAccountId, } -export const get43 = oc +export const get47 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2669,7 +2791,7 @@ export const get43 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistResponse) -export const put9 = oc +export const put10 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2686,11 +2808,11 @@ export const put9 = oc .output(zPutWorkspacesCurrentRbacAgentsByAgentIdWhitelistResponse) export const whitelist = { - get: get43, - put: put9, + get: get47, + put: put10, } -export const get44 = oc +export const get48 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2702,7 +2824,7 @@ export const get44 = oc .output(zGetWorkspacesCurrentRbacAgentsByAgentIdWhitelistConfigResponse) export const whitelistConfig = { - get: get44, + get: get48, } export const byAgentId2 = { @@ -2718,7 +2840,7 @@ export const agents2 = { byAgentId: byAgentId2, } -export const delete11 = oc +export const delete12 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2734,7 +2856,7 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) -export const get45 = oc +export const get49 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2750,11 +2872,11 @@ export const get45 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings2 = { - delete: delete11, - get: get45, + delete: delete12, + get: get49, } -export const get46 = oc +export const get50 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2770,7 +2892,7 @@ export const get46 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings2 = { - get: get46, + get: get50, } export const byPolicyId3 = { @@ -2782,7 +2904,7 @@ export const accessPolicies4 = { byPolicyId: byPolicyId3, } -export const get47 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2799,10 +2921,10 @@ export const get47 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) export const accessPolicy2 = { - get: get47, + get: get51, } -export const get48 = oc +export const get52 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2819,10 +2941,10 @@ export const get48 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) export const userAccessPolicies2 = { - get: get48, + get: get52, } -export const put10 = oc +export const put11 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2839,7 +2961,7 @@ export const put10 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies5 = { - put: put10, + put: put11, } export const byTargetAccountId2 = { @@ -2850,7 +2972,7 @@ export const users2 = { byTargetAccountId: byTargetAccountId2, } -export const get49 = oc +export const get53 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2861,7 +2983,7 @@ export const get49 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) -export const put11 = oc +export const put12 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2878,11 +3000,11 @@ export const put11 = oc .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) export const whitelist2 = { - get: get49, - put: put11, + get: get53, + put: put12, } -export const get50 = oc +export const get54 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2894,7 +3016,7 @@ export const get50 = oc .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistConfigResponse) export const whitelistConfig2 = { - get: get50, + get: get54, } export const byAppId = { @@ -2910,7 +3032,7 @@ export const apps = { byAppId, } -export const delete12 = oc +export const delete13 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2930,7 +3052,7 @@ export const delete12 = oc zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, ) -export const get51 = oc +export const get55 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2950,11 +3072,11 @@ export const get51 = oc ) export const memberBindings3 = { - delete: delete12, - get: get51, + delete: delete13, + get: get55, } -export const get52 = oc +export const get56 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2970,7 +3092,7 @@ export const get52 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings3 = { - get: get52, + get: get56, } export const byPolicyId4 = { @@ -2982,7 +3104,7 @@ export const accessPolicies6 = { byPolicyId: byPolicyId4, } -export const get53 = oc +export const get57 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2999,10 +3121,10 @@ export const get53 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) export const accessPolicy3 = { - get: get53, + get: get57, } -export const get54 = oc +export const get58 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3019,10 +3141,10 @@ export const get54 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) export const userAccessPolicies3 = { - get: get54, + get: get58, } -export const put12 = oc +export const put13 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3039,7 +3161,7 @@ export const put12 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse) export const accessPolicies7 = { - put: put12, + put: put13, } export const byTargetAccountId3 = { @@ -3050,7 +3172,7 @@ export const users3 = { byTargetAccountId: byTargetAccountId3, } -export const get55 = oc +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3061,7 +3183,7 @@ export const get55 = oc .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) -export const put13 = oc +export const put14 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3078,11 +3200,11 @@ export const put13 = oc .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) export const whitelist3 = { - get: get55, - put: put13, + get: get59, + put: put14, } -export const get56 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3094,7 +3216,7 @@ export const get56 = oc .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistConfigResponse) export const whitelistConfig3 = { - get: get56, + get: get60, } export const byDatasetId = { @@ -3110,7 +3232,7 @@ export const datasets = { byDatasetId, } -export const get57 = oc +export const get61 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3126,7 +3248,7 @@ export const get57 = oc ) .output(zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) -export const put14 = oc +export const put15 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3143,8 +3265,8 @@ export const put14 = oc .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) export const rbacRoles = { - get: get57, - put: put14, + get: get61, + put: put15, } export const byMemberId2 = { @@ -3155,7 +3277,7 @@ export const members2 = { byMemberId: byMemberId2, } -export const get58 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3166,10 +3288,10 @@ export const get58 = oc .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) export const myPermissions = { - get: get58, + get: get62, } -export const get59 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3180,10 +3302,10 @@ export const get59 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAgentResponse) export const agent = { - get: get59, + get: get63, } -export const get60 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3194,10 +3316,10 @@ export const get60 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) export const app = { - get: get60, + get: get64, } -export const get61 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3208,10 +3330,10 @@ export const get61 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) export const dataset = { - get: get61, + get: get65, } -export const get62 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3222,7 +3344,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) export const catalog = { - get: get62, + get: get66, agent, app, dataset, @@ -3232,7 +3354,7 @@ export const rolePermissions = { catalog, } -export const post46 = oc +export const post47 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3251,10 +3373,10 @@ export const post46 = oc .output(zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse) export const copy2 = { - post: post46, + post: post47, } -export const get63 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3271,10 +3393,10 @@ export const get63 = oc .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) export const members3 = { - get: get63, + get: get67, } -export const delete13 = oc +export const delete14 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3285,7 +3407,7 @@ export const delete13 = oc .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) -export const get64 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3301,7 +3423,7 @@ export const get64 = oc ) .output(zGetWorkspacesCurrentRbacRolesByRoleIdResponse) -export const put15 = oc +export const put16 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3319,14 +3441,14 @@ export const put15 = oc .output(zPutWorkspacesCurrentRbacRolesByRoleIdResponse) export const byRoleId = { - delete: delete13, - get: get64, - put: put15, + delete: delete14, + get: get68, + put: put16, copy: copy2, members: members3, } -export const get65 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3337,7 +3459,7 @@ export const get65 = oc .input(z.object({ query: zGetWorkspacesCurrentRbacRolesQuery.optional() }).optional()) .output(zGetWorkspacesCurrentRbacRolesResponse) -export const post47 = oc +export const post48 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3355,12 +3477,12 @@ export const post47 = oc .output(zPostWorkspacesCurrentRbacRolesResponse) export const roles = { - get: get65, - post: post47, + get: get69, + post: post48, byRoleId, } -export const put16 = oc +export const put17 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3377,10 +3499,10 @@ export const put16 = oc .output(zPutWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdBindingsResponse) export const bindings = { - put: put16, + put: put17, } -export const get66 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3396,10 +3518,10 @@ export const get66 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings4 = { - get: get66, + get: get70, } -export const get67 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3415,7 +3537,7 @@ export const get67 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings4 = { - get: get67, + get: get71, } export const byPolicyId5 = { @@ -3428,7 +3550,7 @@ export const accessPolicies8 = { byPolicyId: byPolicyId5, } -export const get68 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3439,7 +3561,7 @@ export const get68 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAgentsAccessPolicyResponse) export const accessPolicy4 = { - get: get68, + get: get72, } export const agents3 = { @@ -3447,7 +3569,7 @@ export const agents3 = { accessPolicy: accessPolicy4, } -export const put17 = oc +export const put18 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3464,10 +3586,10 @@ export const put17 = oc .output(zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse) export const bindings2 = { - put: put17, + put: put18, } -export const get69 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3483,10 +3605,10 @@ export const get69 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings5 = { - get: get69, + get: get73, } -export const get70 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3502,7 +3624,7 @@ export const get70 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings5 = { - get: get70, + get: get74, } export const byPolicyId6 = { @@ -3515,7 +3637,7 @@ export const accessPolicies9 = { byPolicyId: byPolicyId6, } -export const get71 = oc +export const get75 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3526,7 +3648,7 @@ export const get71 = oc .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) export const accessPolicy5 = { - get: get71, + get: get75, } export const apps2 = { @@ -3534,7 +3656,7 @@ export const apps2 = { accessPolicy: accessPolicy5, } -export const put18 = oc +export const put19 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3551,10 +3673,10 @@ export const put18 = oc .output(zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse) export const bindings3 = { - put: put18, + put: put19, } -export const get72 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3570,10 +3692,10 @@ export const get72 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) export const memberBindings6 = { - get: get72, + get: get76, } -export const get73 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3589,7 +3711,7 @@ export const get73 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) export const roleBindings6 = { - get: get73, + get: get77, } export const byPolicyId7 = { @@ -3602,7 +3724,7 @@ export const accessPolicies10 = { byPolicyId: byPolicyId7, } -export const get74 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3613,7 +3735,7 @@ export const get74 = oc .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) export const accessPolicy6 = { - get: get74, + get: get78, } export const datasets2 = { @@ -3640,7 +3762,7 @@ export const rbac = { workspace, } -export const post48 = oc +export const post49 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3657,7 +3779,7 @@ export const post48 = oc }) export const upload2 = { - post: post48, + post: post49, } export const files = { @@ -3667,7 +3789,7 @@ export const files = { /** * Import a Skill zip package from multipart form field `file`. */ -export const post49 = oc +export const post50 = oc .route({ description: 'Import a Skill zip package from multipart form field `file`.', inputStructure: 'detailed', @@ -3681,10 +3803,10 @@ export const post49 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const import_ = { - post: post49, + post: post50, } -export const get75 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3695,10 +3817,10 @@ export const get75 = oc .output(zGetWorkspacesCurrentSkillsTagsResponse) export const tags = { - get: get75, + get: get79, } -export const post50 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3716,14 +3838,14 @@ export const post50 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const messages = { - post: post50, + post: post51, } export const assist = { messages, } -export const post51 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3736,10 +3858,10 @@ export const post51 = oc .output(zPostWorkspacesCurrentSkillsBySkillIdDuplicateResponse) export const duplicate = { - post: post51, + post: post52, } -export const get76 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3751,10 +3873,10 @@ export const get76 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdExportResponse) export const export2 = { - get: get76, + get: get80, } -export const post52 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3772,10 +3894,10 @@ export const post52 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const check = { - post: post52, + post: post53, } -export const get77 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3793,10 +3915,10 @@ export const get77 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const content = { - get: get77, + get: get81, } -export const get78 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3814,7 +3936,7 @@ export const get78 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const preview = { - get: get78, + get: get82, } export const patch5 = oc @@ -3834,7 +3956,7 @@ export const patch5 = oc .output(zPatchWorkspacesCurrentSkillsBySkillIdFilesResponse) .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) -export const put19 = oc +export const put20 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -3853,13 +3975,13 @@ export const put19 = oc export const files2 = { patch: patch5, - put: put19, + put: put20, check, content, preview, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3877,10 +3999,10 @@ export const post53 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const publish = { - post: post53, + post: post54, } -export const get79 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3892,10 +4014,10 @@ export const get79 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdReferencesResponse) export const references = { - get: get79, + get: get83, } -export const post54 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3913,10 +4035,10 @@ export const post54 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const restore = { - post: post54, + post: post55, } -export const delete14 = oc +export const delete15 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3927,7 +4049,7 @@ export const delete14 = oc .input(z.object({ params: zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath })) .output(zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse) -export const get80 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3956,12 +4078,12 @@ export const patch6 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const byVersionId = { - delete: delete14, - get: get80, + delete: delete15, + get: get84, patch: patch6, } -export const get81 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3973,11 +4095,11 @@ export const get81 = oc .output(zGetWorkspacesCurrentSkillsBySkillIdVersionsResponse) export const versions = { - get: get81, + get: get85, byVersionId, } -export const delete15 = oc +export const delete16 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -3994,7 +4116,7 @@ export const delete15 = oc .output(zDeleteWorkspacesCurrentSkillsBySkillIdResponse) .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) -export const get82 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4023,8 +4145,8 @@ export const patch7 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const bySkillId = { - delete: delete15, - get: get82, + delete: delete16, + get: get86, patch: patch7, assist, duplicate, @@ -4036,7 +4158,7 @@ export const bySkillId = { versions, } -export const get83 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4047,7 +4169,7 @@ export const get83 = oc .input(z.object({ query: zGetWorkspacesCurrentSkillsQuery.optional() }).optional()) .output(zGetWorkspacesCurrentSkillsResponse) -export const post55 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4061,15 +4183,15 @@ export const post55 = oc .errors({ invalid_request: { status: 400, data: zInvalidRequestError } }) export const skills2 = { - get: get83, - post: post55, + get: get87, + post: post56, files, import: import_, tags, bySkillId, } -export const get84 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4081,10 +4203,10 @@ export const get84 = oc .errors({ current_workspace_archived: { status: 409, data: zCurrentWorkspaceArchivedError } }) export const summary2 = { - get: get84, + get: get88, } -export const get85 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4095,10 +4217,10 @@ export const get85 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get85, + get: get89, } -export const post56 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4110,10 +4232,10 @@ export const post56 = oc .output(zPostWorkspacesCurrentToolProviderApiAddResponse) export const add = { - post: post56, + post: post57, } -export const post57 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4124,11 +4246,11 @@ export const post57 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) -export const delete16 = { - post: post57, +export const delete17 = { + post: post58, } -export const get86 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4139,11 +4261,11 @@ export const get86 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get87 = { - get: get86, +export const get91 = { + get: get90, } -export const get88 = oc +export const get92 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4155,10 +4277,10 @@ export const get88 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get88, + get: get92, } -export const post58 = oc +export const post59 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4170,10 +4292,10 @@ export const post58 = oc .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) export const schema = { - post: post58, + post: post59, } -export const post59 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4185,14 +4307,14 @@ export const post59 = oc .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) export const pre = { - post: post59, + post: post60, } export const test = { pre, } -export const get89 = oc +export const get93 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4204,10 +4326,10 @@ export const get89 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get89, + get: get93, } -export const post60 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4219,13 +4341,13 @@ export const post60 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post60, + post: post61, } export const api = { add, - delete: delete16, - get: get87, + delete: delete17, + get: get91, remote, schema, test, @@ -4233,7 +4355,7 @@ export const api = { update: update2, } -export const post61 = oc +export const post62 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4250,10 +4372,10 @@ export const post61 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post61, + post: post62, } -export const get90 = oc +export const get94 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4270,10 +4392,10 @@ export const get90 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get90, + get: get94, } -export const get91 = oc +export const get95 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4293,7 +4415,7 @@ export const get91 = oc ) export const byCredentialType = { - get: get91, + get: get95, } export const schema2 = { @@ -4305,7 +4427,7 @@ export const credential = { schema: schema2, } -export const get92 = oc +export const get96 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4322,10 +4444,10 @@ export const get92 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get92, + get: get96, } -export const post62 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4342,10 +4464,10 @@ export const post62 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post62, + post: post63, } -export const post63 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4361,11 +4483,11 @@ export const post63 = oc ) .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) -export const delete17 = { - post: post63, +export const delete18 = { + post: post64, } -export const get93 = oc +export const get97 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4377,10 +4499,10 @@ export const get93 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get93, + get: get97, } -export const get94 = oc +export const get98 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4392,10 +4514,10 @@ export const get94 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get94, + get: get98, } -export const get95 = oc +export const get99 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4409,10 +4531,10 @@ export const get95 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get95, + get: get99, } -export const delete18 = oc +export const delete19 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4427,7 +4549,7 @@ export const delete18 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get96 = oc +export const get100 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4440,7 +4562,7 @@ export const get96 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const post64 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4457,9 +4579,9 @@ export const post64 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) export const customClient = { - delete: delete18, - get: get96, - post: post64, + delete: delete19, + get: get100, + post: post65, } export const oauth = { @@ -4467,7 +4589,7 @@ export const oauth = { customClient, } -export const get97 = oc +export const get101 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4479,10 +4601,10 @@ export const get97 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get97, + get: get101, } -export const post65 = oc +export const post66 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4499,7 +4621,7 @@ export const post65 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post65, + post: post66, } export const byProvider2 = { @@ -4507,7 +4629,7 @@ export const byProvider2 = { credential, credentials: credentials3, defaultCredential, - delete: delete17, + delete: delete18, icon: icon2, info: info2, oauth, @@ -4519,7 +4641,7 @@ export const builtin = { byProvider: byProvider2, } -export const post66 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4531,10 +4653,10 @@ export const post66 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post66, + post: post67, } -export const get98 = oc +export const get102 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4546,14 +4668,14 @@ export const get98 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get98, + get: get102, } export const tools3 = { byProviderId, } -export const get99 = oc +export const get103 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4565,14 +4687,14 @@ export const get99 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get99, + get: get103, } export const update4 = { byProviderId: byProviderId2, } -export const delete19 = oc +export const delete20 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4583,7 +4705,7 @@ export const delete19 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -export const post67 = oc +export const post68 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4594,7 +4716,7 @@ export const post67 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) -export const put20 = oc +export const put21 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -4606,15 +4728,15 @@ export const put20 = oc .output(zPutWorkspacesCurrentToolProviderMcpResponse) export const mcp = { - delete: delete19, - post: post67, - put: put20, + delete: delete20, + post: post68, + put: put21, auth, tools: tools3, update: update4, } -export const post68 = oc +export const post69 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4632,10 +4754,10 @@ export const post68 = oc }) export const create2 = { - post: post68, + post: post69, } -export const post69 = oc +export const post70 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4646,11 +4768,11 @@ export const post69 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowDeleteBody })) .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) -export const delete20 = { - post: post69, +export const delete21 = { + post: post70, } -export const get100 = oc +export const get104 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4663,11 +4785,11 @@ export const get100 = oc ) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get101 = { - get: get100, +export const get105 = { + get: get104, } -export const get102 = oc +export const get106 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4679,10 +4801,10 @@ export const get102 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get102, + get: get106, } -export const post70 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4700,13 +4822,13 @@ export const post70 = oc }) export const update5 = { - post: post70, + post: post71, } export const workflow = { create: create2, - delete: delete20, - get: get101, + delete: delete21, + get: get105, tools: tools4, update: update5, } @@ -4718,7 +4840,7 @@ export const toolProvider = { workflow, } -export const get103 = oc +export const get107 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4730,10 +4852,10 @@ export const get103 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get103, + get: get107, } -export const get104 = oc +export const get108 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4744,10 +4866,10 @@ export const get104 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get104, + get: get108, } -export const get105 = oc +export const get109 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4758,10 +4880,10 @@ export const get105 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get105, + get: get109, } -export const get106 = oc +export const get110 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4772,10 +4894,10 @@ export const get106 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get106, + get: get110, } -export const get107 = oc +export const get111 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4786,7 +4908,7 @@ export const get107 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get107, + get: get111, } export const tools5 = { @@ -4796,7 +4918,7 @@ export const tools5 = { workflow: workflow2, } -export const get108 = oc +export const get112 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4808,13 +4930,13 @@ export const get108 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get108, + get: get112, } /** * Get info for a trigger provider */ -export const get109 = oc +export const get113 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4827,13 +4949,13 @@ export const get109 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get109, + get: get113, } /** * Remove custom OAuth client configuration */ -export const delete21 = oc +export const delete22 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -4848,7 +4970,7 @@ export const delete21 = oc /** * Get OAuth client configuration for a provider */ -export const get110 = oc +export const get114 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4863,7 +4985,7 @@ export const get110 = oc /** * Configure custom OAuth client for a provider */ -export const post71 = oc +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4881,9 +5003,9 @@ export const post71 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse) export const client = { - delete: delete21, - get: get110, - post: post71, + delete: delete22, + get: get114, + post: post72, } export const oauth2 = { @@ -4893,7 +5015,7 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider */ -export const post72 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4915,7 +5037,7 @@ export const post72 = oc ) export const bySubscriptionBuilderId = { - post: post72, + post: post73, } export const build = { @@ -4925,7 +5047,7 @@ export const build = { /** * Add a new subscription instance for a trigger provider */ -export const post73 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4943,13 +5065,13 @@ export const post73 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post73, + post: post74, } /** * Get the request logs for a subscription instance for a trigger provider */ -export const get111 = oc +export const get115 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4970,7 +5092,7 @@ export const get111 = oc ) export const bySubscriptionBuilderId2 = { - get: get111, + get: get115, } export const logs = { @@ -4980,7 +5102,7 @@ export const logs = { /** * Update a subscription instance for a trigger provider */ -export const post74 = oc +export const post75 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5002,7 +5124,7 @@ export const post74 = oc ) export const bySubscriptionBuilderId3 = { - post: post74, + post: post75, } export const update6 = { @@ -5012,7 +5134,7 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider */ -export const post75 = oc +export const post76 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5034,7 +5156,7 @@ export const post75 = oc ) export const bySubscriptionBuilderId4 = { - post: post75, + post: post76, } export const verifyAndUpdate = { @@ -5044,7 +5166,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get112 = oc +export const get116 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5065,7 +5187,7 @@ export const get112 = oc ) export const bySubscriptionBuilderId5 = { - get: get112, + get: get116, } export const builder = { @@ -5080,7 +5202,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get113 = oc +export const get117 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5093,13 +5215,13 @@ export const get113 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get113, + get: get117, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get114 = oc +export const get118 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5116,7 +5238,7 @@ export const get114 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get114, + get: get118, } export const oauth3 = { @@ -5126,7 +5248,7 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) */ -export const post76 = oc +export const post77 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5148,7 +5270,7 @@ export const post76 = oc ) export const bySubscriptionId = { - post: post76, + post: post77, } export const verify = { @@ -5172,7 +5294,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post77 = oc +export const post78 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5188,14 +5310,14 @@ export const post77 = oc ) .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) -export const delete22 = { - post: post77, +export const delete23 = { + post: post78, } /** * Update a subscription instance */ -export const post78 = oc +export const post79 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5213,11 +5335,11 @@ export const post78 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post78, + post: post79, } export const subscriptions2 = { - delete: delete22, + delete: delete23, update: update7, } @@ -5233,7 +5355,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get115 = oc +export const get119 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5245,7 +5367,7 @@ export const get115 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get115, + get: get119, } export const current = { @@ -5259,6 +5381,7 @@ export const current = { members, modelProviders, models: models2, + networkAccessGroups, permission, plugin: plugin2, rbac, @@ -5272,7 +5395,7 @@ export const current = { triggers, } -export const post79 = oc +export const post80 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5292,14 +5415,14 @@ export const post79 = oc }) export const upload3 = { - post: post79, + post: post80, } export const webappLogo = { upload: upload3, } -export const get116 = oc +export const get120 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5309,7 +5432,7 @@ export const get116 = oc }) .output(zGetWorkspacesCustomConfigResponse) -export const post80 = oc +export const post81 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5321,12 +5444,12 @@ export const post80 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - get: get116, - post: post80, + get: get120, + post: post81, webappLogo, } -export const post81 = oc +export const post82 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5338,10 +5461,10 @@ export const post81 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post81, + post: post82, } -export const post82 = oc +export const post83 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -5354,10 +5477,10 @@ export const post82 = oc .errors({ account_not_link_tenant: { status: 403, data: zAccountNotLinkTenantError } }) export const switch3 = { - post: post82, + post: post83, } -export const get117 = oc +export const get121 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5369,7 +5492,7 @@ export const get117 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get117, + get: get121, } export const byIconType = { @@ -5388,7 +5511,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get118 = oc +export const get122 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -5399,7 +5522,7 @@ export const get118 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get118, + get: get122, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index fa9bb41b5cff9c..2cb2a9bfd9ca14 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -452,6 +452,43 @@ export type AvailableModelListResponse = { data: Array } +export type NetworkAccessGroupListResponse = { + entitled: boolean + groups: Array + tenant_id: string +} + +export type NetworkAccessGroupCreatePayload = { + allowed_cidrs: Array + description?: string + name: string +} + +export type NetworkAccessGroupMutationResponse = { + group: NetworkAccessGroupResponse +} + +export type NetworkAccessGroupCurrentIpResponse = { + client_ip: string +} + +export type NetworkAccessGroupDeleteResponse = { + deleted: boolean +} + +export type NetworkAccessGroupUpdatePayload = { + allowed_cidrs: Array + description?: string + expected_version: number + name: string +} + +export type NetworkAccessGroupCurrentIpCheckResponse = { + allowed: boolean + client_ip: string + policy_version: number +} + export type WorkspacePermissionResponse = { allow_member_invite: boolean allow_owner_transfer: boolean @@ -1683,6 +1720,22 @@ export type ProviderWithModelsResponse = { tenant_id: string } +export type NetworkAccessGroupResponse = { + allowed_cidrs: Array + app_ids: Array + apps: Array + created_at: string + description?: string + enforcing_count: number + id: string + name: string + tenant_id: string + updated_at: string + updated_by_account_id?: string | null + used_by_count: number + version: number +} + export type PluginAutoUpgradeSettingsPayload = { exclude_plugins?: Array include_plugins?: Array @@ -2390,6 +2443,16 @@ export type ProviderModelWithStatusEntity = { export type CustomConfigurationStatus = 'active' | 'no-configure' +export type NetworkAccessGroupAppResponse = { + bound_agent_id?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: string | null + id: string + mode: string + name: string +} + export type TenantPluginAutoUpgradeStrategySetting = 'disabled' | 'fix_only' | 'latest' export type TenantPluginAutoUpgradeMode = 'all' | 'exclude' | 'partial' @@ -4503,6 +4566,165 @@ export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponses = { export type GetWorkspacesCurrentModelsModelTypesByModelTypeResponse = GetWorkspacesCurrentModelsModelTypesByModelTypeResponses[keyof GetWorkspacesCurrentModelsModelTypesByModelTypeResponses] +export type GetWorkspacesCurrentNetworkAccessGroupsData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/network-access-groups' +} + +export type GetWorkspacesCurrentNetworkAccessGroupsErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsError = + GetWorkspacesCurrentNetworkAccessGroupsErrors[keyof GetWorkspacesCurrentNetworkAccessGroupsErrors] + +export type GetWorkspacesCurrentNetworkAccessGroupsResponses = { + 200: NetworkAccessGroupListResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsResponse = + GetWorkspacesCurrentNetworkAccessGroupsResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsResponses] + +export type PostWorkspacesCurrentNetworkAccessGroupsData = { + body: NetworkAccessGroupCreatePayload + path?: never + query?: never + url: '/workspaces/current/network-access-groups' +} + +export type PostWorkspacesCurrentNetworkAccessGroupsErrors = { + default: ErrorResponse +} + +export type PostWorkspacesCurrentNetworkAccessGroupsError = + PostWorkspacesCurrentNetworkAccessGroupsErrors[keyof PostWorkspacesCurrentNetworkAccessGroupsErrors] + +export type PostWorkspacesCurrentNetworkAccessGroupsResponses = { + 201: NetworkAccessGroupMutationResponse +} + +export type PostWorkspacesCurrentNetworkAccessGroupsResponse = + PostWorkspacesCurrentNetworkAccessGroupsResponses[keyof PostWorkspacesCurrentNetworkAccessGroupsResponses] + +export type GetWorkspacesCurrentNetworkAccessGroupsCurrentIpData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/network-access-groups/current-ip' +} + +export type GetWorkspacesCurrentNetworkAccessGroupsCurrentIpErrors = { + 403: ErrorResponse + 503: ErrorResponse + default: ErrorResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsCurrentIpError = + GetWorkspacesCurrentNetworkAccessGroupsCurrentIpErrors[keyof GetWorkspacesCurrentNetworkAccessGroupsCurrentIpErrors] + +export type GetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponses = { + 200: NetworkAccessGroupCurrentIpResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse = + GetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponses] + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdData = { + body?: never + path: { + group_id: string + } + query: { + expected_version: number + } + url: '/workspaces/current/network-access-groups/{group_id}' +} + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors = { + default: ErrorResponse +} + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdError = + DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors[keyof DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors] + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { + 200: NetworkAccessGroupDeleteResponse +} + +export type DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof DeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdData = { + body?: never + path: { + group_id: string + } + query?: never + url: '/workspaces/current/network-access-groups/{group_id}' +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdError = + GetWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors[keyof GetWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors] + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { + 200: NetworkAccessGroupMutationResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] + +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdData = { + body: NetworkAccessGroupUpdatePayload + path: { + group_id: string + } + query?: never + url: '/workspaces/current/network-access-groups/{group_id}' +} + +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors = { + default: ErrorResponse +} + +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdError = + PutWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors[keyof PutWorkspacesCurrentNetworkAccessGroupsByGroupIdErrors] + +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses = { + 200: NetworkAccessGroupMutationResponse +} + +export type PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses[keyof PutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponses] + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpData = { + body?: never + path: { + group_id: string + } + query?: never + url: '/workspaces/current/network-access-groups/{group_id}/check-current-ip' +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpErrors = { + default: ErrorResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpError = + GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpErrors[keyof GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpErrors] + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponses = { + 200: NetworkAccessGroupCurrentIpCheckResponse +} + +export type GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse = + GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponses[keyof GetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponses] + export type GetWorkspacesCurrentPermissionData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 2358ada2081232..3bdd608b69e583 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -452,6 +452,48 @@ export const zParserPreferredProviderType = z.object({ preferred_provider_type: z.enum(['custom', 'system']), }) +/** + * NetworkAccessGroupCreatePayload + */ +export const zNetworkAccessGroupCreatePayload = z.object({ + allowed_cidrs: z.array(z.string()).min(1).max(100), + description: z.string().max(500).optional().default(''), + name: z.string().min(1).max(100), +}) + +/** + * NetworkAccessGroupCurrentIPResponse + */ +export const zNetworkAccessGroupCurrentIpResponse = z.object({ + client_ip: z.string(), +}) + +/** + * NetworkAccessGroupDeleteResponse + */ +export const zNetworkAccessGroupDeleteResponse = z.object({ + deleted: z.boolean(), +}) + +/** + * NetworkAccessGroupUpdatePayload + */ +export const zNetworkAccessGroupUpdatePayload = z.object({ + allowed_cidrs: z.array(z.string()).min(1).max(100), + description: z.string().max(500).optional().default(''), + expected_version: z.int().gte(1), + name: z.string().min(1).max(100), +}) + +/** + * NetworkAccessGroupCurrentIPCheckResponse + */ +export const zNetworkAccessGroupCurrentIpCheckResponse = z.object({ + allowed: z.boolean(), + client_ip: z.string(), + policy_version: z.int().gte(1), +}) + /** * WorkspacePermissionResponse */ @@ -2667,6 +2709,54 @@ export const zModelProviderSummaryListResponse = z.object({ plugins: z.record(z.string(), zModelProviderPluginSummaryResponse), }) +/** + * NetworkAccessGroupAppResponse + */ +export const zNetworkAccessGroupAppResponse = z.object({ + bound_agent_id: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + mode: z.string(), + name: z.string(), +}) + +/** + * NetworkAccessGroupResponse + */ +export const zNetworkAccessGroupResponse = z.object({ + allowed_cidrs: z.array(z.string()).min(1).max(100), + app_ids: z.array(z.string()), + apps: z.array(zNetworkAccessGroupAppResponse), + created_at: z.iso.datetime(), + description: z.string().optional().default(''), + enforcing_count: z.int().gte(0), + id: z.string(), + name: z.string(), + tenant_id: z.string(), + updated_at: z.iso.datetime(), + updated_by_account_id: z.string().nullish(), + used_by_count: z.int().gte(0), + version: z.int().gte(1), +}) + +/** + * NetworkAccessGroupListResponse + */ +export const zNetworkAccessGroupListResponse = z.object({ + entitled: z.boolean(), + groups: z.array(zNetworkAccessGroupResponse), + tenant_id: z.string(), +}) + +/** + * NetworkAccessGroupMutationResponse + */ +export const zNetworkAccessGroupMutationResponse = z.object({ + group: zNetworkAccessGroupResponse, +}) + /** * TenantPluginAutoUpgradeStrategySetting */ @@ -5064,6 +5154,71 @@ export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ */ export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = zAvailableModelListResponse +/** + * Workspace network access groups retrieved successfully + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsResponse = zNetworkAccessGroupListResponse + +export const zPostWorkspacesCurrentNetworkAccessGroupsBody = zNetworkAccessGroupCreatePayload + +/** + * Workspace network access group created successfully + */ +export const zPostWorkspacesCurrentNetworkAccessGroupsResponse = zNetworkAccessGroupMutationResponse + +/** + * Trusted current client IP retrieved without requiring an existing network access group + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse = + zNetworkAccessGroupCurrentIpResponse + +export const zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ + group_id: z.uuid(), +}) + +export const zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdQuery = z.object({ + expected_version: z.int().gte(1), +}) + +/** + * Workspace network access group deleted successfully + */ +export const zDeleteWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + zNetworkAccessGroupDeleteResponse + +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ + group_id: z.uuid(), +}) + +/** + * Workspace network access group retrieved successfully + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + zNetworkAccessGroupMutationResponse + +export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdBody = + zNetworkAccessGroupUpdatePayload + +export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdPath = z.object({ + group_id: z.uuid(), +}) + +/** + * Workspace network access group updated successfully + */ +export const zPutWorkspacesCurrentNetworkAccessGroupsByGroupIdResponse = + zNetworkAccessGroupMutationResponse + +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath = z.object({ + group_id: z.uuid(), +}) + +/** + * Current client IP checked against the workspace network access group + */ +export const zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse = + zNetworkAccessGroupCurrentIpCheckResponse + /** * Success */ diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 8cfa2e0e530c8f..b7ed9dfc696a34 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -1875,6 +1875,8 @@ export type GetLoginStatusData = { export type GetLoginStatusErrors = { 401: ErrorResponse + 404: ErrorResponse + 503: ErrorResponse default: ErrorResponse } diff --git a/packages/contracts/network-access-current-ip.test.ts b/packages/contracts/network-access-current-ip.test.ts new file mode 100644 index 00000000000000..b68d06a45600a5 --- /dev/null +++ b/packages/contracts/network-access-current-ip.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vite-plus/test' +import { currentIp, workspaces } from './generated/api/console/workspaces/orpc.gen' +import { + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath, + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse, + zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse, +} from './generated/api/console/workspaces/zod.gen' + +describe('generated network access current-IP contract', () => { + it('exposes the policy-independent operation alongside the group preflight', () => { + expect(workspaces.current.networkAccessGroups.currentIp.get).toBe(currentIp.get) + expect(workspaces.current.networkAccessGroups.byGroupId.checkCurrentIp.get).toBeDefined() + }) + + it.each(['192.0.2.1', '2001:db8::1'])('accepts a trusted IP without a saved policy: %s', (ip) => { + expect( + zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse.parse({ client_ip: ip }), + ).toEqual({ client_ip: ip }) + }) + + it.each([{}, null, { client_ip: 42 }])('requires a string client_ip: %j', (response) => { + expect( + zGetWorkspacesCurrentNetworkAccessGroupsCurrentIpResponse.safeParse(response).success, + ).toBe(false) + }) + + it('retains the existing saved-group preflight contract', () => { + const schema = zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpResponse + expect(schema.parse({ client_ip: '192.0.2.1', allowed: false, policy_version: 1 })).toEqual({ + client_ip: '192.0.2.1', + allowed: false, + policy_version: 1, + }) + expect(schema.safeParse({ client_ip: '192.0.2.1' }).success).toBe(false) + expect( + zGetWorkspacesCurrentNetworkAccessGroupsByGroupIdCheckCurrentIpPath.safeParse({}).success, + ).toBe(false) + }) +}) diff --git a/web/.env.example b/web/.env.example index 45dff4d1d60811..28b2a55576badf 100644 --- a/web/.env.example +++ b/web/.env.example @@ -131,3 +131,9 @@ NEXT_PUBLIC_WEB_PREFIX= # number of concurrency NEXT_PUBLIC_BATCH_CONCURRENCY=5 + +# Optional pre-render WebApp availability/IP gate. Use the INTERNAL Gateway, +# never a direct Core API URL. See docs/webapp-document-gate.md before enabling. +# WEBAPP_ACCESS_PREFLIGHT_URL=http://gateway-public:8080/api/webapp/access-mode +# Shared only with an ingress that overwrites both attestation headers. +WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET= diff --git a/web/.storybook/storybook.css b/web/.storybook/storybook.css index 159deff4fee4ea..97ae138474d346 100644 --- a/web/.storybook/storybook.css +++ b/web/.storybook/storybook.css @@ -3,6 +3,7 @@ @source '../app'; @source '../context'; @source '../hooks'; +@source '../features'; @source '.'; @source '../../packages/dify-ui/src'; @source '../node_modules/streamdown/dist'; diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts index 7cb26e66dee41c..2127df3ef5f386 100644 --- a/web/__tests__/proxy-frame-options.spec.ts +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -50,9 +50,9 @@ describe('proxy frame options', () => { expect(canEmbedPath('/apps')).toBe(false) }) - it('should enforce frame ancestors on protected document routes', () => { + it('should enforce frame ancestors on protected document routes', async () => { vi.stubEnv('NODE_ENV', 'production') - const response = proxy(createRequest('https://cloud.dify.ai/device')) + const response = await proxy(createRequest('https://cloud.dify.ai/device')) const contentSecurityPolicy = response.headers.get('content-security-policy') expect(response.headers.get('x-frame-options')).toBe('DENY') @@ -60,9 +60,9 @@ describe('proxy frame options', () => { expect(contentSecurityPolicy).toContain("frame-ancestors 'none'") }) - it('should keep published app routes embeddable', () => { + it('should keep published app routes embeddable', async () => { vi.stubEnv('NODE_ENV', 'production') - const response = proxy(createRequest('https://udify.app/chat/test-token')) + const response = await proxy(createRequest('https://udify.app/chat/test-token')) const contentSecurityPolicy = response.headers.get('content-security-policy') expect(response.headers.get('x-frame-options')).toBeNull() @@ -70,27 +70,27 @@ describe('proxy frame options', () => { expect(contentSecurityPolicy).not.toContain('frame-ancestors') }) - it('should allow Cloudflare Turnstile resources when its site key is configured', () => { + it('should allow Cloudflare Turnstile resources when its site key is configured', async () => { vi.stubEnv('NODE_ENV', 'production') mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = 'site-key-for-tests' - const response = proxy(createRequest('https://cloud.dify.ai/signin')) + const response = await proxy(createRequest('https://cloud.dify.ai/signin')) expect(response.headers.get('content-security-policy')).toContain( 'https://challenges.cloudflare.com', ) }) - it('should protect device routes when global embedding is enabled', () => { + it('should protect device routes when global embedding is enabled', async () => { mockEnv.NEXT_PUBLIC_ALLOW_EMBED = true - const response = proxy(createRequest('https://cloud.dify.ai/device/code')) + const response = await proxy(createRequest('https://cloud.dify.ai/device/code')) expect(response.headers.get('x-frame-options')).toBe('DENY') expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") }) - it('should deny framing for the Marketplace OAuth authorize route', () => { - const response = proxy( + it('should deny framing for the Marketplace OAuth authorize route', async () => { + const response = await proxy( createRequest('https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client'), ) @@ -98,21 +98,21 @@ describe('proxy frame options', () => { expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'") }) - it('should allow framing Marketplace pages when a Marketplace origin is configured', () => { + it('should allow framing Marketplace pages when a Marketplace origin is configured', async () => { vi.stubEnv('NODE_ENV', 'production') mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = 'https://marketplace.dify.ai' - const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + const response = await proxy(createRequest('https://cloud.dify.ai/marketplace')) expect(response.headers.get('content-security-policy') ?? '').toMatch( /frame-src[^;]*https:\/\/marketplace\.dify\.ai/, ) }) - it('should not add a Marketplace frame origin when the prefix is unset', () => { + it('should not add a Marketplace frame origin when the prefix is unset', async () => { vi.stubEnv('NODE_ENV', 'production') - const response = proxy(createRequest('https://cloud.dify.ai/marketplace')) + const response = await proxy(createRequest('https://cloud.dify.ai/marketplace')) const contentSecurityPolicy = response.headers.get('content-security-policy') ?? '' expect(contentSecurityPolicy).toContain('frame-src') @@ -121,9 +121,9 @@ describe('proxy frame options', () => { }) describe('proxy CookieYes consent logging', () => { - it('should allow CookieYes logging requests only through connect-src', () => { + it('should allow CookieYes logging requests only through connect-src', async () => { vi.stubEnv('NODE_ENV', 'production') - const response = proxy(createRequest('https://cloud.dify.ai/signin')) + const response = await proxy(createRequest('https://cloud.dify.ai/signin')) const contentSecurityPolicy = response.headers.get('content-security-policy')! const directives = Object.fromEntries( contentSecurityPolicy @@ -140,8 +140,8 @@ describe('proxy CookieYes consent logging', () => { }) describe('proxy education entry normalization', () => { - it('redirects the legacy education action without leaking it into the canonical URL', () => { - const response = proxy( + it('redirects the legacy education action without leaking it into the canonical URL', async () => { + const response = await proxy( createRequest('https://cloud.dify.ai/?action=getEducationVerify&utm_source=education-site'), ) @@ -151,9 +151,9 @@ describe('proxy education entry normalization', () => { ) }) - it('does not redirect unrelated actions or paths', () => { - const unrelatedAction = proxy(createRequest('https://cloud.dify.ai/?action=showSettings')) - const unrelatedPath = proxy( + it('does not redirect unrelated actions or paths', async () => { + const unrelatedAction = await proxy(createRequest('https://cloud.dify.ai/?action=showSettings')) + const unrelatedPath = await proxy( createRequest('https://cloud.dify.ai/apps?action=getEducationVerify'), ) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 7284aa365efcf0..6c82be34df2da5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -629,4 +629,28 @@ describe('AppDetailLayout', () => { expect(mockRedirect).not.toHaveBeenCalled() }) + + it.each([ + ['agent-1', '/agents/agent-1/access'], + [null, '/agents'], + ])( + 'redirects legacy Agent access-point URLs to the Agent owner (%s)', + async (boundAgentId, target) => { + mockPathname = '/app/app-1/access-point' + mockAppResponse.mockResolvedValue( + createAppDetail({ + mode: 'agent', + bound_agent_id: boundAgentId, + permission_keys: [AppACLPermission.AccessPointView], + }), + ) + render( + +
App page content
+
, + ) + await waitFor(() => expect(mockRedirect).toHaveBeenCalledWith(target)) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + }, + ) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 8a6121f65f12c4..6d6605752c3ff1 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -49,6 +49,9 @@ const getAppDetailRedirectPath = ( pathname: string, permissionOptions: ResourceMaintainerPermissionOptions, ) => { + if (appDetail.mode === 'agent' && pathname.endsWith('/access-point')) + return appDetail.bound_agent_id ? `/agents/${appDetail.bound_agent_id}/access` : '/agents' + const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, permissionOptions) const isWorkflowApp = appDetail.mode === 'workflow' || appDetail.mode === 'advanced-chat' const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index 2e27afe578705a..2142b6a0f88e7c 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' @@ -19,6 +19,14 @@ const webAppAuthMocks = vi.hoisted(() => ({ })) const fetchAccessTokenMock = vi.hoisted(() => vi.fn()) +const ipAccessState = vi.hoisted(() => ({ + scope: {}, + isCurrent: true, + isDenied: false, + deniedError: new Error('IP access denied'), +})) + +const routerMock = { replace: navigationMocks.replace } const originalUrl = window.location.href afterEach(() => { @@ -28,10 +36,17 @@ afterEach(() => { vi.mock('@/next/navigation', () => ({ usePathname: () => navigationMocks.pathname, - useRouter: () => ({ replace: navigationMocks.replace }), + useRouter: () => routerMock, useSearchParams: () => navigationMocks.searchParams, })) +vi.mock('@/features/app-access-error/state', () => ({ + captureAppAccessScope: () => ipAccessState.scope, + hasAppAccessError: () => ipAccessState.isDenied, + isAppAccessScopeCurrent: () => ipAccessState.isCurrent, + isAppAccessError: (error: unknown) => error === ipAccessState.deniedError, +})) + vi.mock('@/service/share', () => ({ fetchAccessToken: (...args: unknown[]) => fetchAccessTokenMock(...args), })) @@ -44,6 +59,9 @@ describe('Splash', () => { useWebAppStore.setState({ shareCode: 'share-app' }) useWebAppStore.setState({ webAppAccessMode: AccessMode.PUBLIC }) useWebAppStore.setState({ embeddedUserId: 'embedded-user' }) + webAppAuthMocks.webAppLogout.mockResolvedValue(undefined) + ipAccessState.isCurrent = true + ipAccessState.isDenied = false navigationMocks.pathname = '/chatbot/share-app' window.history.replaceState({}, '', navigationMocks.pathname) navigationMocks.searchParams = new URLSearchParams({ @@ -227,4 +245,175 @@ describe('Splash', () => { }) expect(navigationMocks.replace).toHaveBeenCalledWith('/webapp-signin?') }) + + it.each(['login status', 'passport'])( + 'should stop initialization when %s is denied by IP policy', + async (request) => { + navigationMocks.searchParams = new URLSearchParams() + if (request === 'login status') { + webAppAuthMocks.webAppLoginStatus.mockRejectedValue(ipAccessState.deniedError) + } else { + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue(ipAccessState.deniedError) + } + + await act(async () => { + render( + +
share application
+
, + ) + }) + + expect(webAppAuthMocks.webAppLoginStatus).toHaveBeenCalledTimes(1) + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(webAppAuthMocks.setWebAppPassport).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + expect(screen.queryByText('share.common.appUnavailable')).not.toBeInTheDocument() + }, + ) + + it('should show an unavailable state when login status fails without allowing the app to load', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockRejectedValue( + createHttpError(new Response(null, { status: 503 })), + ) + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share.common.appUnknownError')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '503' })).toBeInTheDocument() + expect(fetchAccessTokenMock).not.toHaveBeenCalled() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) + + it('should keep authentication and block initialization when the passport policy service is unavailable', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue( + createHttpError(new Response(null, { status: 503 }), { code: 'policy_unavailable' }), + ) + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share.common.appUnknownError')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '503' })).toBeInTheDocument() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(webAppAuthMocks.setWebAppPassport).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) + + it.each(['IP denial', 'navigation', 'unmount'])( + 'should ignore login status received after %s', + async (interruption) => { + navigationMocks.searchParams = new URLSearchParams() + let finishLogin!: (value: { userLoggedIn: boolean; appLoggedIn: boolean }) => void + webAppAuthMocks.webAppLoginStatus.mockReturnValue( + new Promise((resolve) => { + finishLogin = resolve + }), + ) + const { unmount } = render( + +
share application
+
, + ) + + if (interruption === 'IP denial') ipAccessState.isDenied = true + else if (interruption === 'navigation') ipAccessState.isCurrent = false + else unmount() + + await act(async () => { + finishLogin({ userLoggedIn: true, appLoggedIn: false }) + }) + + expect(fetchAccessTokenMock).not.toHaveBeenCalled() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }, + ) + + it.each(['IP denial', 'navigation', 'unmount'])( + 'should ignore a passport received after %s', + async (interruption) => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + let finishPassport!: (value: { access_token: string }) => void + fetchAccessTokenMock.mockReturnValue( + new Promise((resolve) => { + finishPassport = resolve + }), + ) + const { unmount } = render( + +
share application
+
, + ) + await waitFor(() => expect(fetchAccessTokenMock).toHaveBeenCalledTimes(1)) + + if (interruption === 'IP denial') ipAccessState.isDenied = true + else if (interruption === 'navigation') ipAccessState.isCurrent = false + else unmount() + + await act(async () => { + finishPassport({ access_token: 'late-passport' }) + }) + + expect(webAppAuthMocks.setWebAppPassport).not.toHaveBeenCalled() + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }, + ) + + it('should not sign out after a pending passport fails following IP denial', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + let rejectPassport!: (error: Response) => void + fetchAccessTokenMock.mockReturnValue( + new Promise((_resolve, reject) => { + rejectPassport = reject + }), + ) + render( + +
share application
+
, + ) + await waitFor(() => expect(fetchAccessTokenMock).toHaveBeenCalledTimes(1)) + + ipAccessState.isDenied = true + await act(async () => { + rejectPassport(new Response(null, { status: 401 })) + }) + + expect(webAppAuthMocks.webAppLogout).not.toHaveBeenCalled() + expect(navigationMocks.replace).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) }) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 687c29f9191b9b..b1c133144b2734 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -1,5 +1,4 @@ 'use client' - import type { PropsWithChildren } from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -11,9 +10,15 @@ import { import AppUnavailable from '@/app/components/base/app-unavailable' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useWebAppStore } from '@/context/web-app-context' +import { + captureAppAccessScope, + hasAppAccessError, + isAppAccessError, + isAppAccessScopeCurrent, +} from '@/features/app-access-error/state' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' -import { getHttpErrorStatus, isNotFoundError } from '@/service/http-error' +import { getHttpErrorStatus } from '@/service/http-error' import { fetchAccessToken } from '@/service/share' import { resolveWebAppAddress } from '@/service/webapp-address' import { @@ -62,8 +67,17 @@ function Splash({ children }: PropsWithChildren) { }, [getSigninUrl, pathname, redirectUrl, router]) const [isLoading, setIsLoading] = useState(true) - const [unavailableShareCode, setUnavailableShareCode] = useState() + const [initializationError, setInitializationError] = useState<{ + shareCode: string + status?: number + }>() useEffect(() => { + const scope = captureAppAccessScope() + let cancelled = false + const canContinue = () => + !cancelled && (scope === null || isAppAccessScopeCurrent(scope)) && !hasAppAccessError(scope) + if (!canContinue()) return + const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin) const isSigninRoute = isWebAppSigninPath(pathname) if ((redirectUrl !== null && !loginRedirect) || (isSigninRoute && !loginRedirect)) { @@ -80,11 +94,13 @@ function Splash({ children }: PropsWithChildren) { if (tokenFromUrl) setWebAppAccessToken(tokenFromUrl) const redirectOrFinish = () => { + if (!canContinue()) return if (loginRedirect) navigateAfterWebAppLogin(loginRedirect, router.replace, basePath) else setIsLoading(false) } const proceedToAuth = (authenticationRequired: boolean) => { + if (!canContinue()) return if ( authenticationRequired && address.kind === 'environment' && @@ -102,37 +118,61 @@ function Splash({ children }: PropsWithChildren) { setIsLoading(false) } - ;(async () => { - // if access mode is public, user login is always true, but the app login(passport) may be expired - const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( - effectiveShareCode, - embeddedUserId || undefined, - ) - if (userLoggedIn && appLoggedIn) { - redirectOrFinish() - } else if (!userLoggedIn && !appLoggedIn) { - proceedToAuth(true) - } else if (!userLoggedIn && appLoggedIn) { - redirectOrFinish() - } else if (userLoggedIn && !appLoggedIn) { - try { - const { access_token } = await fetchAccessToken({ - appCode: effectiveShareCode, - userId: embeddedUserId || undefined, - }) - setWebAppPassport(address, access_token) + const initialize = async () => { + try { + // Public apps still need a valid app passport even when user login is not required. + const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( + effectiveShareCode, + embeddedUserId || undefined, + ) + if (!canContinue()) return + + if (appLoggedIn) { redirectOrFinish() - } catch (error) { - if (isNotFoundError(error)) { - setUnavailableShareCode(effectiveShareCode) + } else if (!userLoggedIn) { + proceedToAuth(true) + } else { + try { + const { access_token } = await fetchAccessToken({ + appCode: effectiveShareCode, + userId: embeddedUserId || undefined, + }) + if (!canContinue()) return + + setWebAppPassport(address, access_token) + redirectOrFinish() + } catch (error) { + if (!canContinue() || isAppAccessError(error)) return + + if ((getHttpErrorStatus(error) ?? 0) >= 500) { + setInitializationError({ + shareCode: effectiveShareCode, + status: getHttpErrorStatus(error), + }) + return + } + if (getHttpErrorStatus(error) === 404) { + setInitializationError({ shareCode: effectiveShareCode, status: 404 }) + return + } await webAppLogout(address) - return + proceedToAuth(getHttpErrorStatus(error) === 401) } - await webAppLogout(address) - proceedToAuth(getHttpErrorStatus(error) === 401) } + } catch (error) { + if (!canContinue() || isAppAccessError(error)) return + + setInitializationError({ + shareCode: effectiveShareCode, + status: getHttpErrorStatus(error), + }) } - })() + } + + void initialize() + return () => { + cancelled = true + } }, [ shareCode, redirectUrl, @@ -142,6 +182,7 @@ function Splash({ children }: PropsWithChildren) { webAppAccessMode, tokenFromUrl, embeddedUserId, + searchParams, ]) if (message) { @@ -165,10 +206,13 @@ function Splash({ children }: PropsWithChildren) { ) } - if (unavailableShareCode === shareCode) { + if (initializationError?.shareCode === shareCode) { return (
- + $['common.appUnavailable'], { ns: 'share' })} + isUnknownReason={initializationError.status !== 404} + />
) } diff --git a/web/app/__tests__/get-query-client.spec.ts b/web/app/__tests__/get-query-client.spec.ts index d27f907b54fa9d..2a4a0452c2d79c 100644 --- a/web/app/__tests__/get-query-client.spec.ts +++ b/web/app/__tests__/get-query-client.spec.ts @@ -2,6 +2,18 @@ import { dehydrate } from '@tanstack/react-query' import { getQueryClient } from '../get-query-client' describe('getQueryClient', () => { + it('preserves the default of no retries for imperative queries', async () => { + const queryClient = getQueryClient() + const queryKey = ['failed-imperative-query'] + const error = new Error('Unavailable') + const queryFn = vi.fn().mockRejectedValue(error) + + await expect(queryClient.query({ queryKey, queryFn })).rejects.toBe(error) + + expect(queryFn).toHaveBeenCalledOnce() + queryClient.removeQueries({ queryKey }) + }) + it('includes pending queries in dehydrated state', async () => { const queryClient = getQueryClient() const queryKey = ['pending-dehydration'] diff --git a/web/app/__tests__/layout.spec.tsx b/web/app/__tests__/layout.spec.tsx index f08c8f454b5613..8ed5b4258a84d7 100644 --- a/web/app/__tests__/layout.spec.tsx +++ b/web/app/__tests__/layout.spec.tsx @@ -1,3 +1,5 @@ +import { renderToStaticMarkup } from 'react-dom/server' + const mocks = vi.hoisted(() => ({ dehydrateSystemFeatures: vi.fn(() => ({ mutations: [], queries: [] })), getOptionalSystemFeatures: vi.fn(), @@ -34,6 +36,26 @@ describe('Root layout System Features bootstrap', () => { mocks.headers.mockResolvedValue(new Headers()) }) + it('renders the terminal error document without requesting console bootstrap', async () => { + mocks.headers.mockResolvedValue( + new Headers({ + 'x-dify-pathname': '/webapp-unavailable', + 'accept-language': 'zh-CN,zh;q=0.9', + }), + ) + const { default: RootLayout, generateMetadata } = await import('../layout') + + const html = renderToStaticMarkup(await RootLayout({ children:
App unavailable
})) + const document = new DOMParser().parseFromString(html, 'text/html') + + expect(document.documentElement.lang).toBe('zh-Hans') + expect(document.body.querySelector('main')).toHaveTextContent('App unavailable') + await expect(generateMetadata()).resolves.toEqual({ icons: { icon: '/favicon.ico' } }) + + expect(mocks.getOptionalSystemFeatures).not.toHaveBeenCalled() + expect(mocks.dehydrateSystemFeatures).not.toHaveBeenCalled() + }) + it('uses optional System Features for branded metadata', async () => { mocks.getOptionalSystemFeatures.mockResolvedValue({ branding: { diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index 16bc2dc6f5ca9d..3dad7524762607 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -3,8 +3,7 @@ import type { ReactElement } from 'react' import { screen } from '@testing-library/react' import { consoleQuery } from '@/service/console' import { createHttpError } from '@/service/http-error' -import { createQueryClientWrapper } from '@/test/console/query-client' -import { render as renderWithConsole } from '@/test/console/render' +import { renderWithConsoleQuery as renderWithConsole } from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { BuiltInAccessPoints } from '../built-in-access-points' @@ -35,12 +34,18 @@ vi.mock('@/features/system-features/client', () => ({ function render(ui: ReactElement) { const queryClient = createTestQueryClient() - queryClient.setQueryData(['system-features'], { webapp_auth: { enabled: true } }) + queryClient.setQueryData(['system-features'], { + webapp_auth: { enabled: true }, + rbac_enabled: true, + }) queryClient.setQueryData( consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }), createAppDetailFixture({ ...mocks.appInfo, mode: mocks.appInfo.mode as AppMode }), ) - return renderWithConsole(ui, { wrapper: createQueryClientWrapper(queryClient) }) + return renderWithConsole(ui, { + queryClient, + currentWorkspace: { id: 'workspace-1', role: 'owner' }, + }) } vi.mock('@/context/i18n', () => ({ @@ -109,6 +114,7 @@ describe('BuiltInAccessPoints', () => { it('renders the unpublished state across all access point cards', () => { render( { render( { ) }) + it('keeps an unpublished Chatflow unavailable without showing a Trigger card', () => { + mocks.appInfo = { + ...mocks.appInfo, + mode: 'advanced-chat', + enable_site: true, + enable_api: true, + } + render( + , + ) + + expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() + expect(mocks.webCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable' }), + ) + expect(mocks.apiCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable' }), + ) + expect(mocks.mcpCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable' }), + ) + expect(mocks.triggerCard).not.toHaveBeenCalled() + expect(mocks.useAppWorkflow).toHaveBeenCalledWith('app-1', expect.any(Object)) + }) + + it.each([ + ['loading', { data: null, isError: false, isPending: true }, 'loading'], + ['failed', { data: null, isError: true, isPending: false }, 'unavailable'], + [ + 'published', + { + data: { graph: { nodes: [{ data: { type: 'start' } }] } }, + isError: false, + isPending: false, + }, + 'available', + ], + ] as const)( + 'shows Chatflow access points as %s while published workflow is %s', + (_state, workflow, availability) => { + mocks.appInfo = { + ...mocks.appInfo, + mode: 'advanced-chat', + enable_site: true, + enable_api: true, + } + mocks.workflow = workflow + render( + , + ) + + expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability })) + expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability })) + expect(mocks.triggerCard).not.toHaveBeenCalled() + expect( + screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'), + ).not.toBeInTheDocument() + }, + ) + it('uses Access Point management for every requested built-in operation', () => { render( { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith( @@ -211,7 +297,7 @@ describe('BuiltInAccessPoints', () => { expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.mcpCard).toHaveBeenCalledWith( - expect.objectContaining({ triggerModeDisabled: true }), + expect.objectContaining({ availability: 'unavailable' }), ) expect(mocks.triggerCard).toHaveBeenCalledWith( expect.objectContaining({ availability: 'available' }), @@ -229,7 +315,13 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) @@ -247,7 +339,13 @@ describe('BuiltInAccessPoints', () => { } render( - , + , ) expect( @@ -257,7 +355,13 @@ describe('BuiltInAccessPoints', () => { it('does not retry forbidden published workflow requests', () => { render( - , + , ) const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as { diff --git a/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx new file mode 100644 index 00000000000000..09a0a5c8aa170e --- /dev/null +++ b/web/app/components/app/access-point/__tests__/built-in-permissions.spec.tsx @@ -0,0 +1,267 @@ +import type { + AppDetailWithSite, + WorkflowResponse, + WorkflowTriggerResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { toast } from '@/app/notifications' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' +import { BuiltInAccessPoints } from '../built-in-access-points' + +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => path })) +vi.mock('@/service/use-triggers', () => ({ useAllTriggerPlugins: () => ({ data: [] }) })) +vi.mock('@/app/components/app/overview/customize', () => ({ + default: () => null, + CustomizeDialog: () => null, +})) +vi.mock('@/app/components/app/overview/settings', () => ({ SettingsDialog: () => null })) +vi.mock('@/app/components/app/overview/embedded', () => ({ EmbeddedDialogContent: () => null })) + +const inService = 'agentV2.agentDetail.access.status.inService' +const unavailable = 'deployments.health.ENVIRONMENT_STATUS_FAILED' +const unpublishedTitle = 'deployments.studio.accessPoint.noPublishedTitle' +const publishedPath = '/apps/app-1/workflows/publish' + +const getWebCard = () => + screen.getByRole('region', { name: 'agentV2.agentDetail.access.webApp.title' }) +const getApiCard = () => + screen.getByRole('region', { name: 'agentV2.agentDetail.access.serviceApi.title' }) +const getMcpCard = () => screen.getByRole('region', { name: 'tools.mcp.server.title' }) +const getTriggerCard = () => screen.getByRole('region', { name: 'navigation.settings.trigger' }) + +const publishedWorkflow: WorkflowResponse = { + id: 'published-workflow', + graph: { nodes: [{ data: { type: 'start' } }] }, + features: {}, + conversation_variables: [], + environment_variables: [], + rag_pipeline_variables: [], + created_at: 1, + updated_at: 1, + hash: 'published-hash', + marked_comment: '', + marked_name: '', + tool_published: false, + version: 'published', +} + +function renderAccessPoints({ + mode = 'workflow', + role = 'normal', + published = true, + triggers = [], + workflow = publishedWorkflow, + rbacEnabled = false, + canViewLayout = role === 'owner', + triggerResponse, +}: { + mode?: AppDetailWithSite['mode'] + role?: 'normal' | 'owner' + published?: boolean + triggers?: WorkflowTriggerResponse[] + workflow?: WorkflowResponse | null + rbacEnabled?: boolean + canViewLayout?: boolean + triggerResponse?: Promise +} = {}) { + const paths: string[] = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = new Request(input, init) + const path = new URL(request.url).pathname.replace('/console/api', '') + if (request.method === 'POST' && path === '/apps/app-1/trigger-enable' && role === 'owner') { + const body = (await request.json()) as { trigger_id: string; enable_trigger: boolean } + triggers = triggers.map((trigger) => + trigger.id === body.trigger_id + ? { ...trigger, status: body.enable_trigger ? 'enabled' : 'disabled' } + : trigger, + ) + return Response.json({ result: 'success' }) + } + if (request.method !== 'GET') throw new Error(`Unexpected mutation: ${request.method} ${path}`) + paths.push(path) + if (path === publishedPath) { + return role === 'normal' && !rbacEnabled + ? Response.json( + { + code: 'forbidden', + message: "You don't have the permission to access the requested resource.", + }, + { status: 403 }, + ) + : Response.json(workflow) + } + if (path === '/apps/app-1/server') + return Response.json({ id: 'server-1', server_code: 'server-code', status: 'active' }) + if (path === '/apps/app-1/triggers') return triggerResponse ?? Response.json({ data: triggers }) + throw new Error(`Unexpected request: ${request.method} ${path}`) + }) + const queryClient = createConsoleQueryClient() + seedAppDetail( + queryClient, + createAppDetailFixture({ + id: 'app-1', + mode, + site: createAppSiteFixture(), + workflow: published ? { id: 'published-workflow' } : null, + }), + ) + renderWithConsoleQuery( + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD', rbac_enabled: rbacEnabled }, + }, + ) + return paths +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +it.each(['workflow', 'advanced-chat'] as const)( + 'shows a published %s to Normal members without requesting its graph', + async (mode) => { + const paths = renderAccessPoints({ mode }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() + expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() + expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument() + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + for (const card of [getWebCard(), getApiCard(), getMcpCard()]) + expect(within(card).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength( + mode === 'workflow' ? 1 : 0, + ) + }, +) + +it('uses the app summary to identify an unpublished workflow for Normal members', async () => { + const paths = renderAccessPoints({ published: false }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + expect(screen.getByText(unpublishedTitle)).toBeInTheDocument() + expect(within(getWebCard()).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(toast.error).not.toHaveBeenCalled() +}) + +it('keeps published Owner access points available using the workflow graph', async () => { + const paths = renderAccessPoints({ role: 'owner' }) + + await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) + expect(within(getWebCard()).getByText(inService)).toBeInTheDocument() + expect(within(getApiCard()).getByText(inService)).toBeInTheDocument() + expect(within(getTriggerCard()).getByText(unavailable)).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() +}) + +it.each(['disabled', 'unauthorized'])( + 'preserves trigger-only availability for a Normal member when its trigger is %s', + async (status) => { + const paths = renderAccessPoints({ + triggers: [ + { + id: 'trigger-1', + node_id: 'trigger-node', + title: 'Published trigger', + trigger_type: 'trigger-webhook', + status, + icon: '', + provider_name: '', + }, + ], + }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(paths).not.toContain(publishedPath) + for (const card of [getWebCard(), getApiCard(), getMcpCard()]) + expect(within(card).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(within(getTriggerCard()).getByText('Published trigger')).toBeInTheDocument() + expect(within(getTriggerCard()).getByRole('switch')).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'), + ).toBeInTheDocument() + expect(toast.error).not.toHaveBeenCalled() + }, +) + +it('waits for published trigger metadata before showing Normal access point availability', async () => { + let finish!: (response: Response) => void + const triggerResponse = new Promise((resolve) => { + finish = resolve + }) + const paths = renderAccessPoints({ triggerResponse }) + + await waitFor(() => expect(paths).toContain('/apps/app-1/triggers')) + expect(getWebCard()).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() + + finish(Response.json({ data: [] })) + await waitFor(() => expect(within(getWebCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(1) + expect(paths).not.toContain(publishedPath) +}) + +it('honors RBAC layout access even for a Normal workspace member', async () => { + const paths = renderAccessPoints({ rbacEnabled: true, canViewLayout: true }) + + await waitFor(() => expect(within(getMcpCard()).getByText(inService)).toBeInTheDocument()) + expect(paths.filter((path) => path === publishedPath)).toHaveLength(1) + expect(toast.error).not.toHaveBeenCalled() +}) + +it('keeps Owner MCP unavailable when the published graph has no Start node', async () => { + renderAccessPoints({ role: 'owner', workflow: { ...publishedWorkflow, graph: { nodes: [] } } }) + + await waitFor(() => expect(getMcpCard()).not.toHaveAttribute('aria-busy', 'true')) + expect(within(getMcpCard()).getAllByText(unavailable).length).toBeGreaterThan(0) + expect(screen.queryByText(unpublishedTitle)).not.toBeInTheDocument() +}) + +it('refreshes the shared trigger query after an Owner changes a published trigger', async () => { + const user = userEvent.setup() + const paths = renderAccessPoints({ + role: 'owner', + workflow: { ...publishedWorkflow, graph: { nodes: [{ data: { type: 'trigger-webhook' } }] } }, + triggers: [ + { + id: 'trigger-1', + node_id: 'trigger-node', + title: 'Published trigger', + trigger_type: 'trigger-webhook', + status: 'enabled', + icon: '', + provider_name: '', + }, + ], + }) + + const toggle = await within(getTriggerCard()).findByRole('switch', { name: 'Published trigger' }) + expect(toggle).toHaveAttribute('aria-checked', 'true') + await user.click(toggle) + + await waitFor(() => + expect(paths.filter((path) => path === '/apps/app-1/triggers')).toHaveLength(2), + ) + expect(toggle).toHaveAttribute('aria-checked', 'false') + expect(toast.error).not.toHaveBeenCalled() +}) diff --git a/web/app/components/app/access-point/__tests__/environment-tabs.browser.spec.tsx b/web/app/components/app/access-point/__tests__/environment-tabs.browser.spec.tsx index f7d7ee5cbdaed6..5a18dc76cc1491 100644 --- a/web/app/components/app/access-point/__tests__/environment-tabs.browser.spec.tsx +++ b/web/app/components/app/access-point/__tests__/environment-tabs.browser.spec.tsx @@ -5,6 +5,8 @@ import { page, userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' +import { seedSystemFeatures } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { createAppDetailFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' @@ -37,6 +39,8 @@ vi.mock('@/app/components/app/access-point/deployed-environment-access-points', async function renderAccessPoint(names = ['test-1']) { const queryClient = createTestQueryClient() seedAccountProfileQuery(queryClient) + seedCurrentWorkspaceQuery(queryClient) + seedSystemFeatures(queryClient) queryClient.setQueryData( consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }), createAppDetailFixture({ diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx index feefc4ecb3673c..e7f79ed5c78240 100644 --- a/web/app/components/app/access-point/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -8,14 +8,22 @@ import userEvent from '@testing-library/user-event' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { consoleQuery } from '@/service/console' import { seedAccountProfileQuery } from '@/test/console/account-profile' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' +import { + createNetworkAccessGroupFixture, + seedAppNetworkAccessGroup, + seedNetworkAccessGroups, +} from '@/test/console/network-access' +import { seedSystemFeatures } from '@/test/console/query-data' import { QueryClientTestProvider } from '@/test/console/query-provider' import { render } from '@/test/console/render' -import { createAppDetailFixture } from '@/test/fixtures/app' +import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app' import { createTestQueryClient } from '@/test/query-client' import { AppACLPermission } from '@/utils/permission' import AccessPoint from '..' let appMode: AppMode = 'workflow' +let appPublished = false let appPermissionKeys: string[] = [AppACLPermission.AccessPointView] const accessPointMocks = vi.hoisted(() => ({ builtIn: vi.fn(), @@ -28,7 +36,10 @@ const mockConsoleState = vi.hoisted(() => ({ vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') + const { default: translations } = await import('@/i18n/locales/en-US/deployments.json') return createReactI18nextMock({ + ...translations, + 'operation.save': 'Save', 'workflow.nodes.common.memories.builtIn': 'Built-in', }) }) @@ -90,17 +101,27 @@ const appEnvironments: AppEnvironment[] = [ const renderAccessPoint = ({ environments = appEnvironments, searchParams = '', + cloud = false, }: { environments?: AppEnvironment[] searchParams?: string + cloud?: boolean } = {}) => { const queryClient = createTestQueryClient() seedAccountProfileQuery(queryClient, mockConsoleState.userProfile) + seedCurrentWorkspaceQuery(queryClient) + seedSystemFeatures(queryClient, { deployment_edition: cloud ? 'CLOUD' : 'COMMUNITY' }) + if (cloud) { + seedNetworkAccessGroups(queryClient, { groups: [createNetworkAccessGroupFixture()] }) + seedAppNetworkAccessGroup(queryClient, 'app-1') + } queryClient.setQueryData( consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }), createAppDetailFixture({ id: 'app-1', mode: appMode, + workflow: appPublished ? { id: 'published-workflow' } : null, + model_config: appPublished ? createAppModelConfigFixture() : null, maintainer: 'user-2', permission_keys: appPermissionKeys, }), @@ -134,9 +155,18 @@ describe('AccessPoint', () => { beforeEach(() => { vi.clearAllMocks() appMode = 'workflow' + appPublished = false appPermissionKeys = [AppACLPermission.AccessPointView] }) + it('does not show Access Control on community edition', () => { + renderAccessPoint() + + expect( + screen.queryByRole('button', { name: /Access Control|studio\.accessControl\.entryLabel/ }), + ).not.toBeInTheDocument() + }) + it('renders Built-in and only in-use environments from the API', () => { renderAccessPoint() @@ -319,4 +349,30 @@ describe('AccessPoint', () => { }), ) }) + + it.each(['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion'] as const)( + 'checks published %s apps before applying a policy that excludes the current IP', + async (mode) => { + const user = userEvent.setup() + appMode = mode + appPublished = true + appPermissionKeys = [AppACLPermission.AccessPointManage] + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ allowed: false, client_ip: '203.0.113.42', policy_version: 1 }), + ) + renderAccessPoint({ cloud: true }) + await user.click(screen.getByRole('button', { name: /Access Control/ })) + await user.click(screen.getByRole('button', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect( + await screen.findByRole('alertdialog', { name: 'Save without your own IP?' }), + ).toBeInTheDocument() + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.every(([input, init]) => new Request(input, init).method === 'GET'), + ).toBe(true) + }, + ) }) diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index d81f289b0e11a1..fd297f76669a81 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -131,9 +131,8 @@ function renderCard(cardAppInfo: AppDetailWithSite = appInfo, workflow?: Publish , ) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index 7617b83e6bad58..e80c744fe42a86 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -3,10 +3,12 @@ import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { useSuspenseQuery } from '@tanstack/react-query' +import { skipToken, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { useDocLink } from '@/context/i18n' +import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { consoleQuery } from '@/service/console' @@ -21,6 +23,7 @@ import { WebAppAccessPointCard } from './web-app-card' type BuiltInAccessPointsProps = { appId: string + canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -29,6 +32,7 @@ type BuiltInAccessPointsProps = { export function BuiltInAccessPoints({ appId, + canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, @@ -40,7 +44,13 @@ export function BuiltInAccessPoints({ consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), ) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) + const workspace = useAtomValue(currentWorkspaceAtom) + const workspaceLoading = useAtomValue(currentWorkspaceLoadingAtom) + const canReadWorkflow = + canViewLayout && + (systemFeatures.rbac_enabled || + (!workspaceLoading && ['owner', 'admin', 'editor'].includes(workspace.role))) + const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo) && canReadWorkflow) const { data: workflow, isError: workflowError, @@ -52,14 +62,34 @@ export function BuiltInAccessPoints({ return failureCount < 3 }, }) + const useWorkflowSummary = Boolean(appInfo && isAdvancedApp(appInfo) && !canReadWorkflow) + const needsPublishedTriggers = + useWorkflowSummary && appInfo?.mode === 'workflow' && Boolean(appInfo.workflow?.id) + const triggersQuery = useQuery( + consoleQuery.apps.byAppId.triggers.get.queryOptions({ + input: appInfo?.mode === 'workflow' ? { params: { app_id: appId } } : skipToken, + enabled: needsPublishedTriggers, + retry: false, + }), + ) const actions = useAccessPointActions(appId, canManageAccessPoint) if (!appInfo) return const workflowState = getPublishedWorkflowState(appInfo, workflow) - const builtInLoading = workflowState.isWorkflowApp && workflowLoading + const isUnpublished = useWorkflowSummary ? !appInfo.workflow?.id : workflowState.isUnpublished + const hasTriggerNode = useWorkflowSummary + ? needsPublishedTriggers && Boolean(triggersQuery.data?.data.length) + : workflowState.hasTriggerNode + const builtInLoading = + workflowState.requiresPublishedWorkflow && + (workspaceLoading || + (shouldFetchWorkflow && workflowLoading) || + (needsPublishedTriggers && triggersQuery.isPending)) + const builtInError = + (shouldFetchWorkflow && workflowError) || (needsPublishedTriggers && triggersQuery.isError) const appCardsUnavailable = - workflowState.isWorkflowApp && (workflowState.isUnpublished || workflowState.hasTriggerNode) + workflowState.requiresPublishedWorkflow && (builtInError || isUnpublished || hasTriggerNode) const appCardAvailability = builtInLoading ? 'loading' : appCardsUnavailable @@ -67,13 +97,20 @@ export function BuiltInAccessPoints({ : 'available' const triggerAvailability = builtInLoading ? 'loading' - : workflowState.isUnpublished || !workflowState.hasTriggerNode + : builtInError || isUnpublished || !hasTriggerNode + ? 'unavailable' + : 'available' + const mcpAvailability = builtInLoading + ? 'loading' + : appCardsUnavailable || + (!workflowState.requiresPublishedWorkflow && !appInfo.model_config?.updated_at) || + (canReadWorkflow && workflowState.isWorkflowApp && !workflowState.hasStartNode) ? 'unavailable' : 'available' return (
- {workflowState.isUnpublished && !workflowLoading && !workflowError && ( + {isUnpublished && !builtInLoading && !builtInError && (
@@ -129,8 +166,7 @@ export function BuiltInAccessPoints({ appInfo={appInfo} canManageAccessPoint={canManageAccessPoint} workflow={workflow} - workflowLoading={workflowLoading} - triggerModeDisabled={workflowState.hasTriggerNode} + availability={mcpAvailability} highlighted={highlightedAccessPoint === 'mcp'} /> {workflowState.isWorkflowApp && ( @@ -143,7 +179,7 @@ export function BuiltInAccessPoints({ )}
- {workflowState.hasTriggerNode && ( + {hasTriggerNode && (
diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index bcea03c2a8d598..a8d64e282aaa29 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -2,6 +2,7 @@ import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { PublishedWorkflow } from '../shared/utils' +import type { AccessPointAvailability } from '@/app/components/base/access-point/status' import { AlertDialog, AlertDialogCancelButton, @@ -30,23 +31,20 @@ type MCPAccessPointCardProps = { appInfo: AppDetailWithSite canManageAccessPoint: boolean highlighted?: boolean - triggerModeDisabled: boolean + availability: AccessPointAvailability workflow: PublishedWorkflow - workflowLoading: boolean } export function MCPAccessPointCard({ appInfo, canManageAccessPoint, highlighted, - triggerModeDisabled, + availability, workflow, - workflowLoading, }: MCPAccessPointCardProps) { const { t } = useTranslation(['appOverview', 'common', 'deployments', 'tools']) const advancedApp = isAdvancedApp(appInfo) const basicApp = !advancedApp - const workflowApp = appInfo.mode === 'workflow' const [showServerModal, setShowServerModal] = useState(false) const [showRegenerate, setShowRegenerate] = useState(false) const basicConfig = appInfo.model_config @@ -86,11 +84,8 @@ export function MCPAccessPointCard({ ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp` : '***********' const workflowNodes = getPublishedWorkflowNodes(workflow) - const missingStartNode = - workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start) - const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at - const loading = serverDetailLoading || (advancedApp && workflowLoading) - const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled) + const loading = serverDetailLoading || availability === 'loading' + const unavailable = !loading && availability === 'unavailable' const basicAppInputs = useMemo(() => { if (!basicApp || !basicAppInputForm) return [] diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx index f4361a255025e5..b7ce3f14855a1b 100644 --- a/web/app/components/app/access-point/index.tsx +++ b/web/app/components/app/access-point/index.tsx @@ -8,13 +8,14 @@ import { ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' -import { useSuspenseQueries } from '@tanstack/react-query' +import { useSuspenseQueries, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs' import { useTranslation } from 'react-i18next' import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { AccessControlEntry } from '@/features/network-access/app-binding' import { consoleQuery } from '@/service/console' import { getAppACLCapabilities } from '@/utils/permission' import { BuiltInAccessPoints } from './built-in-access-points' @@ -39,6 +40,7 @@ type AccessPointProps = { } type AccessPointContentProps = AccessPointProps & { + canViewLayout: boolean canDeploy: boolean canManageAccessPoint: boolean canReleaseAndVersion: boolean @@ -47,12 +49,16 @@ type AccessPointContentProps = AccessPointProps & { function AccessPointContent({ appId, + canViewLayout, canDeploy, canManageAccessPoint, canReleaseAndVersion, showEnvironmentTabs, }: AccessPointContentProps) { const { t } = useTranslation(['common', 'deployments', 'workflow']) + const { data: appInfo } = useSuspenseQuery( + consoleQuery.apps.byAppId.get.queryOptions({ input: { params: { app_id: appId } } }), + ) const environments = useAtomValue(inUseAppEnvironmentsAtom) const [queryStates, setQueryStates] = useQueryStates(accessPointQueryStates) const { accessPoint: highlightedAccessPoint, environment } = queryStates @@ -73,10 +79,24 @@ function AccessPointContent({ >
-
+

{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}

+ {appInfo && ( + + )}

{t(($) => $['studio.accessPoint.description'], { ns: 'deployments' })} @@ -127,6 +147,7 @@ function AccessPointContent({ {selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? ( node.data.type === BlockEnum.Start) - const hasTriggerNode = nodes.some((node) => isTriggerNode(node.data.type)) + const hasTriggerNode = isWorkflowApp && nodes.some((node) => isTriggerNode(node.data.type)) return { hasStartNode, hasTriggerNode, - isUnpublished: isWorkflowApp && !workflow?.graph, + isUnpublished: requiresPublishedWorkflow && !workflow?.graph, isWorkflowApp, + requiresPublishedWorkflow, } } diff --git a/web/app/components/base/amplitude/__tests__/access-point-page-view.spec.ts b/web/app/components/base/amplitude/__tests__/access-point-page-view.spec.ts new file mode 100644 index 00000000000000..c6fa3f3a2d0823 --- /dev/null +++ b/web/app/components/base/amplitude/__tests__/access-point-page-view.spec.ts @@ -0,0 +1,113 @@ +import type { Types } from '@amplitude/analytics-browser' +import * as amplitude from '@amplitude/analytics-browser' +import { getQueryClient } from '@/app/get-query-client' +import { consoleQuery } from '@/service/console' +import { createAgentFixture } from '@/test/fixtures/agent' +import { initializeAmplitudeSDK } from '../runtime' + +vi.mock('@amplitude/analytics-browser', () => ({ + init: vi.fn(), + add: vi.fn(), + setOptOut: vi.fn(), + track: vi.fn(), + flush: vi.fn(), + setUserId: vi.fn(), + Identify: vi.fn(), + identify: vi.fn(), + reset: vi.fn(), +})) +vi.mock('@amplitude/plugin-session-replay-browser', () => ({ + sessionReplayPlugin: vi.fn(() => ({ name: 'replay' })), +})) + +const pageView = (url: string): Types.Event => ({ + event_type: '[Amplitude] Page Viewed', + event_properties: { '[Amplitude] Page URL': url }, +}) +const getPlugin = () => { + initializeAmplitudeSDK('test-key', 0) + return vi.mocked(amplitude.add).mock.calls[0]![0] as Types.EnrichmentPlugin +} + +beforeEach(() => { + vi.clearAllMocks() + getQueryClient().clear() +}) + +it('registers enrichment before initialization can capture the first page view', () => { + getPlugin() + expect(vi.mocked(amplitude.add).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(amplitude.init).mock.invocationCallOrder[0]!, + ) +}) + +it.each([ + ['https://cloud.dify.ai/app/app-a/access-point', 'app-a'], + ['https://cloud.dify.ai/console/app/app-b/access-point?settings=ip-policies', 'app-b'], +])('enriches %s from the event URL even after navigation', async (url, appId) => { + const event = pageView(url) + const result = await getPlugin().execute!(event) + expect(result?.event_properties).toMatchObject({ app_id: appId }) + expect(amplitude.track).not.toHaveBeenCalled() +}) + +it('uses the associated App ID from the cached Agent query', async () => { + const options = consoleQuery.agent.byAgentId.get.queryOptions({ + input: { params: { agent_id: 'agent-a' } }, + }) + getQueryClient().setQueryData( + options.queryKey, + createAgentFixture({ id: 'agent-a', app_id: 'backing-app', hidden_app_backed: false }), + ) + const result = await getPlugin().execute!(pageView('https://cloud.dify.ai/agents/agent-a/access')) + expect(result?.event_properties).toMatchObject({ app_id: 'backing-app' }) + expect(globalThis.fetch).not.toHaveBeenCalled() +}) + +it('keeps a failed Agent lookup page view without inventing an App ID', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + Response.json({ message: 'Unavailable' }, { status: 503 }), + ) + const event = pageView('https://cloud.dify.ai/agents/agent-a/access') + expect(await getPlugin().execute!(event)).toBe(event) + expect(event.event_properties).not.toHaveProperty('app_id') +}) + +it('keeps a pending Agent lookup attached to its original page view during navigation', async () => { + let resolveAgent!: (response: Response) => void + vi.mocked(globalThis.fetch).mockImplementation( + () => + new Promise((resolve) => { + resolveAgent = resolve + }), + ) + const plugin = getPlugin() + const event = pageView('https://cloud.dify.ai/agents/agent-a/access') + const pending = plugin.execute!(event) + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledOnce()) + window.history.replaceState({}, '', '/app/app-b/access-point') + const next = await plugin.execute!(pageView(window.location.href)) + resolveAgent( + Response.json( + createAgentFixture({ id: 'agent-a', app_id: 'backing-app-a', hidden_app_backed: false }), + ), + ) + expect((await pending)?.event_properties).toMatchObject({ app_id: 'backing-app-a' }) + expect(next?.event_properties).toMatchObject({ app_id: 'app-b' }) + expect(amplitude.track).not.toHaveBeenCalled() + window.history.replaceState({}, '', '/') +}) + +it('leaves other pages and custom events without Access Point properties', async () => { + const plugin = getPlugin() + const event = pageView('https://cloud.dify.ai/apps') + await plugin.execute!(event) + expect(event.event_properties).not.toHaveProperty('app_id') + const custom = { + ...pageView('https://cloud.dify.ai/app/app-a/access-point'), + event_type: 'custom', + } + await plugin.execute!(custom) + expect(custom.event_properties).not.toHaveProperty('app_id') + expect(globalThis.fetch).not.toHaveBeenCalled() +}) diff --git a/web/app/components/base/amplitude/access-point-page-properties.ts b/web/app/components/base/amplitude/access-point-page-properties.ts new file mode 100644 index 00000000000000..fa8625d41a86ff --- /dev/null +++ b/web/app/components/base/amplitude/access-point-page-properties.ts @@ -0,0 +1,14 @@ +import { getQueryClient } from '@/app/get-query-client' +import { consoleQuery } from '@/service/console' + +export async function getAgentAccessAppId(agentId: string) { + const agent = await getQueryClient().query( + consoleQuery.agent.byAgentId.get.queryOptions({ + input: { params: { agent_id: agentId } }, + context: { silent: true }, + staleTime: Infinity, + retry: false, + }), + ) + return agent.hidden_app_backed ? undefined : agent.app_id +} diff --git a/web/app/components/base/amplitude/runtime.ts b/web/app/components/base/amplitude/runtime.ts index f91131d44db7a1..395191a9191398 100644 --- a/web/app/components/base/amplitude/runtime.ts +++ b/web/app/components/base/amplitude/runtime.ts @@ -35,6 +35,27 @@ const createPageNameEnrichmentPlugin = (): Types.EnrichmentPlugin => { /* v8 ignore next @preserve */ const pathname = typeof window !== 'undefined' ? window.location.pathname : '' event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname) + const pageUrl = event.event_properties['[Amplitude] Page URL'] + if (typeof pageUrl === 'string') { + let eventPath: string + try { + eventPath = new URL(pageUrl).pathname + } catch { + return event + } + const appMatch = /\/app\/([^/]+)\/access-point\/?$/.exec(eventPath) + if (appMatch) event.event_properties.app_id = appMatch[1] + const agentMatch = /\/agents\/([^/]+)\/access\/?$/.exec(eventPath) + if (agentMatch?.[1]) { + try { + const { getAgentAccessAppId } = await import('./access-point-page-properties') + const appId = await getAgentAccessAppId(agentMatch[1]) + if (appId) event.event_properties.app_id = appId + } catch { + // Preserve the page view when the app identity cannot be resolved. + } + } + } } return event }, @@ -42,6 +63,7 @@ const createPageNameEnrichmentPlugin = (): Types.EnrichmentPlugin => { } export function initializeAmplitudeSDK(apiKey: string, sessionReplaySampleRate: number) { + amplitude.add(createPageNameEnrichmentPlugin()) amplitude.init(apiKey, { defaultTracking: { sessions: true, @@ -51,7 +73,6 @@ export function initializeAmplitudeSDK(apiKey: string, sessionReplaySampleRate: attribution: true, }, }) - amplitude.add(createPageNameEnrichmentPlugin()) amplitude.add(sessionReplayPlugin({ sampleRate: sessionReplaySampleRate })) amplitude.setOptOut(false) return { diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index b674b0c752a4cf..3be396ca243175 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -2,6 +2,8 @@ import type { ChatConfig, ChatItemInTree } from '../../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import { act } from '@testing-library/react' import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types' +import { toast } from '@/app/notifications' +import { captureAppAccessScope, handleAppAccessError } from '@/features/app-access-error/state' import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost } from '@/service/base' import { renderHookWithConsoleQuery } from '@/test/console/query-data' @@ -46,6 +48,15 @@ const createAbortControllerMock = () => { vi.spyOn(controller, 'abort') return controller } +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} const createPausedChatTree = (): ChatItemInTree[] => [ { id: 'q-1', @@ -96,6 +107,8 @@ type HookCallbacks = { describe('useChat', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureAppAccessScope() vi.mocked(ssePost).mockReset().mockResolvedValue(undefined) vi.mocked(sseGet).mockReset().mockResolvedValue(undefined) audio.getAudioPlayer @@ -890,6 +903,215 @@ describe('useChat', () => { ]) }) + it('should retain the streamed answer and original settlement when history fails', async () => { + const historyError = new Response(null, { status: 503 }) + const onGetConversationMessages = vi.fn().mockRejectedValue(historyError) + const onGetSuggestedQuestions = vi.fn() + const onConversationComplete = vi.fn() + const onSendSettled = vi.fn() + const { result } = renderHookWithConsoleQuery(() => + useChat({ + suggested_questions_after_answer: { enabled: true }, + } as ChatConfig), + ) + + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + onGetConversationMessages, + onGetSuggestedQuestions, + onConversationComplete, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + await act(async () => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + await expect(callbacks.onCompleted()).resolves.toBeUndefined() + }) + + expect(result.current.chatList.at(-1)?.content).toBe('Streamed answer') + expect(result.current.isResponding).toBe(false) + expect(onGetSuggestedQuestions).not.toHaveBeenCalled() + expect(onConversationComplete).not.toHaveBeenCalled() + expect(onSendSettled).toHaveBeenCalledExactlyOnceWith(undefined) + expect(toast.error).not.toHaveBeenCalled() + }) + + it.each(['denied', 'navigation', 'new request', 'stop', 'unmount'] as const)( + 'should ignore history results after %s', + async (transition) => { + window.history.replaceState({}, '', '/agent/history-test') + const scope = captureAppAccessScope() + const history = createDeferred() + const onGetConversationMessages = vi.fn(() => history.promise) + const onGetSuggestedQuestions = vi.fn() + const onConversationComplete = vi.fn() + const onSendSettled = vi.fn() + const { result, unmount } = renderHookWithConsoleQuery(() => + useChat( + { suggested_questions_after_answer: { enabled: true } } as ChatConfig, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { isNewAgent: true }, + ), + ) + + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + isPublicAPI: true, + onGetConversationMessages, + onGetSuggestedQuestions, + onConversationComplete, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + let completion: ReturnType + act(() => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + completion = callbacks.onCompleted() + }) + expect(onGetConversationMessages).toHaveBeenCalledOnce() + + act(() => { + if (transition === 'navigation') window.history.replaceState({}, '', '/agent/another-app') + if (transition === 'new request') + result.current.handleSend( + 'chat-messages', + { query: 'next question' }, + { isPublicAPI: true }, + ) + if (transition === 'stop') result.current.handleStop() + if (transition === 'unmount') unmount() + if (transition === 'denied') + handleAppAccessError(403, { code: 'ip_access_denied' }, scope) + }) + const chatListBeforeHistory = result.current.chatList + await act(async () => { + history.resolve({ data: [{ id: 'answer-1', answer: 'Late history answer' }] }) + await completion + }) + + expect(result.current.chatList).toEqual(chatListBeforeHistory) + expect(onGetSuggestedQuestions).not.toHaveBeenCalled() + expect(onConversationComplete).not.toHaveBeenCalled() + expect(onSendSettled).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it.each(['denied', 'navigation', 'unmount'] as const)( + 'should consume history rejection after %s without completion callbacks', + async (transition) => { + window.history.replaceState({}, '', '/agent/history-rejection') + const scope = captureAppAccessScope() + const history = createDeferred() + const onGetSuggestedQuestions = vi.fn() + const onConversationComplete = vi.fn() + const onSendSettled = vi.fn() + const { result, unmount } = renderHookWithConsoleQuery(() => useChat()) + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + isPublicAPI: true, + onGetConversationMessages: () => history.promise, + onGetSuggestedQuestions, + onConversationComplete, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + let completion: ReturnType + act(() => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + completion = callbacks.onCompleted() + }) + + await act(async () => { + const error = new Response(null, { status: transition === 'denied' ? 403 : 503 }) + if (transition === 'denied') + handleAppAccessError(403, { code: 'ip_access_denied' }, scope, error) + if (transition === 'navigation') window.history.replaceState({}, '', '/chat/another-app') + if (transition === 'unmount') unmount() + history.reject(error) + await expect(completion).resolves.toBeUndefined() + }) + + expect(result.current.chatList.at(-1)?.content).toBe('Streamed answer') + expect(onGetSuggestedQuestions).not.toHaveBeenCalled() + expect(onConversationComplete).not.toHaveBeenCalled() + expect(onSendSettled).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }, + ) + + it.each(['resolve', 'reject'] as const)( + 'should ignore a suggested question %s after navigation', + async (outcome) => { + window.history.replaceState({}, '', '/agent/suggestions-test') + const suggestions = createDeferred() + const onSendSettled = vi.fn() + const { result } = renderHookWithConsoleQuery(() => + useChat({ + suggested_questions_after_answer: { enabled: true }, + } as ChatConfig), + ) + act(() => { + result.current.handleSend( + 'chat-messages', + { query: 'test' }, + { + isPublicAPI: true, + onGetSuggestedQuestions: () => suggestions.promise, + onSendSettled, + }, + ) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + let completion: ReturnType + act(() => { + callbacks.onData('Streamed answer', true, { + messageId: 'answer-1', + conversationId: 'conversation-1', + }) + completion = callbacks.onCompleted() + }) + await act(async () => { + window.history.replaceState({}, '', '/agent/another-app') + if (outcome === 'resolve') suggestions.resolve({ data: ['Late suggestion'] }) + else suggestions.reject(new Error('Late failure')) + await completion + }) + + expect(result.current.suggestedQuestions).toEqual([]) + expect(onSendSettled).not.toHaveBeenCalled() + }, + ) + it.each([ ['UTC', '10:00 AM'], ['Asia/Shanghai', '06:00 PM'], @@ -2055,6 +2277,71 @@ describe('useChat', () => { }) describe('handleStop and handleRestart', () => { + it.each(['rejection', 'throw'] as const)( + 'should finish stopping locally after a service %s', + async (failure) => { + const error = new Error('Stop request failed') + const stopChat = vi.fn(() => { + if (failure === 'throw') throw error + return Promise.reject(error) + }) + const { result, unmount } = renderHookWithConsoleQuery(() => + useChat(undefined, undefined, undefined, stopChat), + ) + act(() => { + result.current.handleSend('chat-messages', { query: 'test' }, {}) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + const controller = createAbortControllerMock() + await act(async () => { + callbacks.onWorkflowStarted({ task_id: 'task-123' }) + callbacks.getAbortController(controller) + result.current.handleStop() + }) + + expect(result.current.isResponding).toBe(false) + expect(controller.abort).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + unmount() + }, + ) + + it.each([false, true])( + 'should consume a rejected stop request when unmounted is %s', + async (shouldUnmount) => { + window.history.replaceState({}, '', '/agent/stop-test') + const scope = captureAppAccessScope() + const error = new Response(null, { status: 403 }) + const stopRequest = createDeferred() + const stopChat = vi.fn(() => stopRequest.promise) + const { result, unmount } = renderHookWithConsoleQuery(() => + useChat(undefined, undefined, undefined, stopChat), + ) + + act(() => { + result.current.handleSend('chat-messages', { query: 'test' }, { isPublicAPI: true }) + }) + const callbacks = vi.mocked(ssePost).mock.calls[0]![2] as HookCallbacks + const controller = createAbortControllerMock() + act(() => { + callbacks.onWorkflowStarted({ task_id: 'task-123' }) + callbacks.getAbortController(controller) + result.current.handleStop() + }) + + expect(stopChat).toHaveBeenCalledWith('task-123') + expect(result.current.isResponding).toBe(false) + expect(controller.abort).toHaveBeenCalledOnce() + + await act(async () => { + if (shouldUnmount) unmount() + handleAppAccessError(403, { code: 'ip_access_denied' }, scope, error) + stopRequest.reject(error) + }) + expect(toast.error).not.toHaveBeenCalled() + }, + ) + it('clears messages and suggested questions when restarting', async () => { const cb = vi.fn() const { result } = renderHookWithConsoleQuery(() => diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index e06d6f5b806523..850970aed16f3d 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -22,6 +22,11 @@ import { isInstalledAppPath } from '@/app/components/explore/installed-app/route import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' +import { + captureAppAccessScope, + hasAppAccessError, + isAppAccessScopeCurrent, +} from '@/features/app-access-error/state' import useTimestamp from '@/hooks/use-timestamp' import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost } from '@/service/base' @@ -204,7 +209,7 @@ export const useChat = ( inputsForm: InputForm[] }, prevChatTree?: ChatItemInTree[], - stopChat?: (taskId: string) => void, + stopChat?: (taskId: string) => void | Promise, clearChatList?: boolean, clearChatListCallback?: (state: boolean) => void, initialConversationId?: string, @@ -539,7 +544,15 @@ export const useChat = ( const handleStop = useCallback(() => { hasStopRespondedRef.current = true handleResponding(false) - if (stopChat && taskIdRef.current && !pausedStateRef.current) stopChat(taskIdRef.current) + if (stopChat && taskIdRef.current && !pausedStateRef.current) { + void (async () => { + try { + await stopChat(taskIdRef.current) + } catch { + // The request layer owns error feedback; stopping locally must still finish. + } + })() + } if (conversationMessagesAbortControllerRef.current) conversationMessagesAbortControllerRef.current.abort() if (suggestedQuestionsAbortControllerRef.current) @@ -1110,6 +1123,11 @@ export const useChat = ( pausedStateRef.current = false resetWorkflowEventsSubscription() const requestGeneration = ++workflowRequestGenerationRef.current + const appAccessScope = isPublicAPI ? captureAppAccessScope() : null + const isCurrentRequest = () => + requestGeneration === workflowRequestGenerationRef.current && + isAppAccessScopeCurrent(appAccessScope) && + !hasAppAccessError(appAccessScope) const parentMessage = threadMessages.find((item) => item.id === data.parent_message_id) @@ -1182,13 +1200,13 @@ export const useChat = ( let hasNotifiedConversationComplete = false let currentWorkflowRunId = '' const settleSend = (hasError?: boolean) => { - if (hasSettled) return + if (hasSettled || !isCurrentRequest()) return hasSettled = true onSendSettled?.(hasError) } const notifyConversationComplete = (workflowRunId?: string) => { - if (hasNotifiedConversationComplete) return + if (hasNotifiedConversationComplete || !isCurrentRequest()) return hasNotifiedConversationComplete = true onConversationComplete?.(conversationIdRef.current, workflowRunId) @@ -1255,7 +1273,7 @@ export const useChat = ( }) }, async onCompleted(hasError?: boolean) { - if (requestGeneration !== workflowRequestGenerationRef.current) return + if (!isCurrentRequest()) return handleResponding(false) @@ -1269,11 +1287,22 @@ export const useChat = ( !hasStopRespondedRef.current && onGetConversationMessages ) { - const conversationMessagesResponse = await onGetConversationMessages( - conversationIdRef.current, - (newAbortController) => - (conversationMessagesAbortControllerRef.current = newAbortController), - ) + let conversationMessagesResponse: unknown + try { + conversationMessagesResponse = await onGetConversationMessages( + conversationIdRef.current, + (newAbortController) => { + if (isCurrentRequest()) + conversationMessagesAbortControllerRef.current = newAbortController + else newAbortController.abort() + }, + ) + } catch { + // Keep the streamed answer and its settlement when history cannot be refreshed. + return + } + if (!isCurrentRequest()) return + const data = getConversationMessagesData(conversationMessagesResponse) const newResponseItem = data.find((item) => item.id === responseItem.id) completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId @@ -1336,6 +1365,7 @@ export const useChat = ( notifyConversationComplete(completedWorkflowRunId) if ( + isCurrentRequest() && config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions @@ -1343,12 +1373,15 @@ export const useChat = ( try { const { data }: any = await onGetSuggestedQuestions( responseItem.id, - (newAbortController) => - (suggestedQuestionsAbortControllerRef.current = newAbortController), + (newAbortController) => { + if (isCurrentRequest()) + suggestedQuestionsAbortControllerRef.current = newAbortController + else newAbortController.abort() + }, ) - setSuggestedQuestions(data) + if (isCurrentRequest()) setSuggestedQuestions(data) } catch { - setSuggestedQuestions([]) + if (isCurrentRequest()) setSuggestedQuestions([]) } } } finally { diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 1c185ac85f742c..a5865f22f0b8ca 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -5,6 +5,7 @@ import { ORPCError } from '@orpc/client' import { act, renderHook, waitFor } from '@testing-library/react' import { createElement } from 'react' import { toast } from '@/app/notifications' +import { captureAppAccessScope, handleAppAccessError } from '@/features/app-access-error/state' import { uploadRemoteFileInfo } from '@/service/common' import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' @@ -88,6 +89,8 @@ function setup({ beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureAppAccessScope() navigation.params = {} navigation.pathname = '/chat' vi.spyOn(toast, 'error').mockReturnValue('toast-id') @@ -347,3 +350,39 @@ it('advances pending remote progress only to 80 percent and completes from the r }), ) }) + +it.each(['local', 'retry', 'remote'] as const)( + 'settles a denied or cancelled %s upload without duplicate error feedback', + async (uploadKind) => { + for (const cancelled of [false, true]) { + const error = cancelled + ? new DOMException('Upload cancelled', 'AbortError') + : new ORPCError('ip_access_denied', { status: 403 }) + if (!cancelled) { + window.history.replaceState({}, '', '/chat/restricted-app') + handleAppAccessError(403, { code: 'ip_access_denied' }, captureAppAccessScope(), error) + } + const { result, unmount } = setup({ files: uploadKind === 'retry' ? [entity('retry')] : [] }) + if (uploadKind === 'remote') { + vi.mocked(uploadRemoteFileInfo).mockRejectedValueOnce(error) + await act(async () => result.current.handleLoadFileFromLink('https://example.com/file')) + expect(result.current.files).toEqual([]) + } else { + if (uploadKind === 'retry') act(() => result.current.handleReUploadFile('retry')) + else { + act(() => + result.current.handleLocalFileUpload( + new File(['a'], 'one.txt', { type: 'text/plain' }), + ), + ) + await waitFor(() => expect(fileUpload).toHaveBeenCalled()) + } + act(() => vi.mocked(fileUpload).mock.calls.at(-1)![0].onErrorCallback(error)) + expect(result.current.files[0]?.progress).toBe(-1) + } + expect(toast.error).not.toHaveBeenCalled() + unmount() + vi.mocked(fileUpload).mockClear() + } + }, +) diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index a5f6a33ec2ae4c..0552e22f1661cc 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -17,11 +17,13 @@ import { } from '@/app/components/base/file-uploader/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { toast } from '@/app/notifications' +import { isAppAccessError } from '@/features/app-access-error/state' import { useParams, usePathname } from '@/next/navigation' import { uploadRemoteFileInfo } from '@/service/common' import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } from '@/service/share' import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' +import { isAbortError } from '@/utils/is-abort-error' import { useFileStore } from './store' import { useFileUploadContext } from './upload-context' import { @@ -191,7 +193,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isAppAccessError(error) && !isAbortError(error)) toast.error(errorMessage) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, } @@ -278,8 +280,9 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { handleRemoveFile(uploadingFile.id) else handleUpdateFile(newFile) }) - .catch(() => { - toast.error(t(($) => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })) + .catch((error: unknown) => { + if (!isAppAccessError(error) && !isAbortError(error)) + toast.error(t(($) => $['fileUploader.pasteFileLinkInvalid'], { ns: 'common' })) handleRemoveFile(uploadingFile.id) }) }, @@ -372,7 +375,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => { t(($) => $['fileUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isAppAccessError(error) && !isAbortError(error)) toast.error(errorMessage) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, } diff --git a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts index 3505bffbc191a3..83cca8ac143e89 100644 --- a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts @@ -1,9 +1,20 @@ import type { ImageFile } from '@/types/app' import { act, renderHook } from '@testing-library/react' +import { captureAppAccessScope, handleAppAccessError } from '@/features/app-access-error/state' import { TransferMethod } from '@/types/app' import { useImageFiles, useLocalFileUploader } from '../hooks' const mockNotify = vi.fn() + +const createUploadError = (ipDenied: boolean | 'cancelled') => { + if (ipDenied === 'cancelled') return new DOMException('Upload cancelled', 'AbortError') + const error = new Error('Upload failed') + if (ipDenied) { + window.history.replaceState({}, '', '/chat/restricted-app') + handleAppAccessError(403, { code: 'ip_access_denied' }, captureAppAccessScope(), error) + } + return error +} vi.mock('@/app/notifications', () => ({ toast: { error: (message: string) => mockNotify({ type: 'error', message }), @@ -184,31 +195,36 @@ describe('useImageFiles', () => { expect(result.current.files[0]!.progress).toBe(100) }) - it('should set progress to -1 and notify on error callback during re-upload', () => { - const { result } = renderHook(() => useImageFiles()) - const file = new File(['test'], 'test.png', { type: 'image/png' }) - const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) - - act(() => { - result.current.onUpload(imageFile) - }) - - act(() => { - result.current.onReUpload('file-1') - }) - - const uploadCall = mockImageUpload.mock.calls[0]![0] - - act(() => { - uploadCall.onErrorCallback(new Error('Network error')) - }) - - expect(result.current.files[0]!.progress).toBe(-1) - expect(mockNotify).toHaveBeenCalledWith({ - type: 'error', - message: 'common.imageUploader.uploadFromComputerUploadError', - }) - }) + it.each([false, true, 'cancelled'] as const)( + 'sets failed re-upload progress and suppresses IP denial or cancellation (%s)', + (ipDenied) => { + const { result } = renderHook(() => useImageFiles()) + const file = new File(['test'], 'test.png', { type: 'image/png' }) + const imageFile = createImageFile({ _id: 'file-1', file, progress: -1 }) + + act(() => { + result.current.onUpload(imageFile) + }) + + act(() => { + result.current.onReUpload('file-1') + }) + + const uploadCall = mockImageUpload.mock.calls[0]![0] + + act(() => { + uploadCall.onErrorCallback(createUploadError(ipDenied)) + }) + + expect(result.current.files[0]!.progress).toBe(-1) + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.imageUploader.uploadFromComputerUploadError', + }) + }, + ) }) it('preserves concurrent uploads and ignores retry callbacks after removal or clearing', () => { @@ -333,27 +349,31 @@ describe('useLocalFileUploader', () => { ) }) - it('should notify error and call onUpload with progress -1 on upload failure', async () => { - const onUpload = vi.fn() - const { result } = renderHook(() => useLocalFileUploader({ onUpload })) + it.each([false, true, 'cancelled'] as const)( + 'sets failed local upload progress and suppresses IP denial or cancellation (%s)', + async (ipDenied) => { + const onUpload = vi.fn() + const { result } = renderHook(() => useLocalFileUploader({ onUpload })) - const file = new File(['test'], 'test.png', { type: 'image/png' }) + const file = new File(['test'], 'test.png', { type: 'image/png' }) - act(() => { - result.current.handleLocalFileUpload(file) - }) + act(() => { + result.current.handleLocalFileUpload(file) + }) - await vi.waitFor(() => { - expect(mockImageUpload).toHaveBeenCalled() - }) + await vi.waitFor(() => { + expect(mockImageUpload).toHaveBeenCalled() + }) - const uploadCall = mockImageUpload.mock.calls[0]![0] + const uploadCall = mockImageUpload.mock.calls[0]![0] - act(() => { - uploadCall.onErrorCallback(new Error('fail')) - }) + act(() => { + uploadCall.onErrorCallback(createUploadError(ipDenied)) + }) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) - expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: -1 })) - }) + if (ipDenied) expect(mockNotify).not.toHaveBeenCalled() + else expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(onUpload).toHaveBeenCalledWith(expect.objectContaining({ progress: -1 })) + }, + ) }) diff --git a/web/app/components/base/image-uploader/hooks.ts b/web/app/components/base/image-uploader/hooks.ts index 4572eb984c9054..4a0a70f0e9d6e7 100644 --- a/web/app/components/base/image-uploader/hooks.ts +++ b/web/app/components/base/image-uploader/hooks.ts @@ -2,8 +2,10 @@ import type { ImageFile } from '@/types/app' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from '@/app/notifications' +import { isAppAccessError } from '@/features/app-access-error/state' import { useParams } from '@/next/navigation' import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app' +import { isAbortError } from '@/utils/is-abort-error' import { getImageUploadErrorMessage, imageUpload } from './utils' export const useImageFiles = () => { @@ -96,7 +98,7 @@ export const useImageFiles = () => { t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isAppAccessError(error) && !isAbortError(error)) toast.error(errorMessage) updateCurrentFile({ progress: -1 }) }, }, @@ -175,7 +177,7 @@ export const useLocalFileUploader = ({ t(($) => $['imageUploader.uploadFromComputerUploadError'], { ns: 'common' }), t, ) - toast.error(errorMessage) + if (!isAppAccessError(error) && !isAbortError(error)) toast.error(errorMessage) onUpload({ ...imageFile, progress: -1 }) }, }, diff --git a/web/app/components/explore/try-app/__tests__/app-not-found.spec.tsx b/web/app/components/explore/try-app/__tests__/app-not-found.spec.tsx new file mode 100644 index 00000000000000..65ee9822ebc25e --- /dev/null +++ b/web/app/components/explore/try-app/__tests__/app-not-found.spec.tsx @@ -0,0 +1,67 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' +import { toast } from '@/app/notifications' +import { + appAccessErrorAtom, + appAccessStore, + captureAppAccessScope, + isAppAccessError, +} from '@/features/app-access-error/state' +import enCommon from '@/i18n/locales/en-US/common.json' +import enShare from '@/i18n/locales/en-US/share.json' +import TryApp from '../index' + +vi.unmock('react-i18next') +vi.mock('@/next/navigation', () => ({ + usePathname: () => window.location.pathname, + useSearchParams: () => new URLSearchParams(window.location.search), +})) +vi.mock('../app', () => ({ default: () => null })) +vi.mock('../preview', () => ({ default: () => null })) +vi.mock('../app-info', () => ({ default: () => null })) +vi.mock('@/app/notifications', () => ({ toast: { error: vi.fn() } })) + +it('shows the unified error inside a dismissible trial dialog, retaining the parent page', async () => { + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']) + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + keySeparator: false, + resources: { 'en-US': { common: enCommon } }, + }) + window.history.replaceState({}, '', '/explore/apps') + captureAppAccessScope() + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ code: 'app_not_found', message: 'App not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }), + ) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: (count, error) => !isAppAccessError(error) && count < 3 } }, + }) + const onClose = vi.fn() + const user = userEvent.setup() + render( + + +

Explore apps

+ + + , + ) + expect( + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + expect(screen.getByText('Explore apps')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + expect(appAccessStore.get(appAccessErrorAtom)).toBeNull() + await user.click(screen.getByRole('button', { name: enCommon['operation.close'] })) + expect(onClose).toHaveBeenCalledOnce() + queryClient.clear() + vi.restoreAllMocks() +}) diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 9a234968b0f9b4..7341a82f442793 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -10,6 +10,7 @@ import * as React from 'react' import { Suspense, useId, useRef } from 'react' import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' +import TrialAppAccessBoundary from '@/features/app-access-error/trial-boundary' import { consoleQuery } from '@/service/console' import AppInfo from './app-info' import Preview from './preview' @@ -29,7 +30,7 @@ type Props = Readonly<{ onCreate: () => void }> -function TryApp({ +function TryAppContent({ appId, canTrial, categories, @@ -39,7 +40,8 @@ function TryApp({ createButtonStepByStepTourTarget, onClose, onCreate, -}: Props) { + detailTabRef, +}: Props & { detailTabRef: React.RefObject }) { const { t } = useTranslation(['common', 'explore']) const { data: appDetail, @@ -77,7 +79,6 @@ function TryApp({ hasLoadError || errorUpdateCount > 0 || composerQuery.errorUpdateCount > 0 const isRetrying = isFetching || composerQuery.isFetching const retryLabelId = useId() - const detailTabRef = useRef(null) const handleRetry = async () => { const results = await Promise.all([ @@ -94,158 +95,169 @@ function TryApp({ } return ( - { - if (!open) onClose() - }} - disablePointerDismissal - > - + + {templateName ?? appDetail?.name ?? t(($) => $['apps.title'], { ns: 'explore' })} + + $['operation.close'], { ns: 'common' })} + onClick={onClose} > - - {templateName ?? appDetail?.name ?? t(($) => $['apps.title'], { ns: 'explore' })} - - $['operation.close'], { ns: 'common' })} - onClick={onClose} - > - - - -
- + + + +
+ + + + {t(($) => $['tryApp.tabHeader.detail'], { ns: 'explore' })} + + + {canTrial && ( - {t(($) => $['tryApp.tabHeader.detail'], { ns: 'explore' })} + {t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })} - {canTrial && ( - - - {t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })} - - - )} - -
-
+ )} + +
+
+ + {isInitialLoading ? ( +
+ +
+ ) : ( +
+ {keepErrorForExit && ( +
+
+ + + +

+ {t(($) => $['tryApp.loadError'], { ns: 'explore' })} +

+
+ +
+ )} + {appDetail && !hasLoadError && ( +
+ + +
+ } + > + + +
+ )} +
+ )} + + {canTrial && ( - {isInitialLoading ? ( -
- -
- ) : ( -
- {keepErrorForExit && ( -
-
- - - -

- {t(($) => $['tryApp.loadError'], { ns: 'explore' })} -

-
- -
- )} - {appDetail && !hasLoadError && ( -
- - -
- } - > - - + {appDetail && !hasLoadError && !agentLoading && ( + +
- )} -
+ } + > + + )} - {canTrial && ( - - {appDetail && !hasLoadError && !agentLoading && ( - - -
- } - > - - - )} - - )} - {appDetail && !hasLoadError && !agentLoading && ( - }> - - - )} -
- + )} + {appDetail && !hasLoadError && !agentLoading && ( + }> + + + )} +
+ + + ) +} + +function TryApp(props: Props) { + const detailTabRef = useRef(null) + return ( + { + if (!open) props.onClose() + }} + disablePointerDismissal + > + + + + ) diff --git a/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx b/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx new file mode 100644 index 00000000000000..6948fae18cb696 --- /dev/null +++ b/web/app/components/header/account-dropdown/__tests__/focus.browser.spec.tsx @@ -0,0 +1,151 @@ +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { useQueryState } from 'nuqs' +import { userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { + settingsQueryParamName, + settingsQueryParser, +} from '@/app/components/header/account-setting/query-params' +import AccountSection from '@/app/components/main-nav/components/account-section' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' + +vi.mock('@/service/use-common', () => ({ useLogout: () => ({ mutate: vi.fn() }) })) +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'system', setTheme: vi.fn() }), +})) + +// The settings contents are outside the menu's focus contract; retain a real destination dialog. +function PreferencesDestination() { + const [destination] = useQueryState(settingsQueryParamName, settingsQueryParser) + return ( + + + Preferences destination + + + + ) +} + +async function renderAccount() { + const { wrapper: ConsoleProvider } = createConsoleQueryWrapper({ + accountProfile: { name: 'Current User', email: 'current@example.com' }, + features: { education: { enabled: false } }, + }) + const { wrapper: NuqsProvider } = createNuqsTestWrapper() + return render( + + +
+ + +
+ +
+
, + ) +} + +// Chromium supplies the native focus-visible heuristic after menu focus restoration. +it.each(['outside', 'Escape'])( + 'does not highlight a pointer-opened account trigger after %s dismissal', + async (dismiss) => { + const screen = await renderAccount() + const trigger = screen.getByRole('button', { + name: 'Current User accountSettings.account.account', + }) + await trigger.click() + await expect.element(screen.getByRole('menu')).toBeVisible() + if (dismiss === 'Escape') await userEvent.keyboard('{Escape}') + else + await screen + .getByRole('presentation') + .filter({ hasNotText: 'current@example.com' }) + .click({ position: { x: 10, y: 600 } }) + await expect.element(screen.getByRole('menu')).not.toBeInTheDocument() + if (dismiss === 'Escape') await expect.element(trigger).toHaveFocus() + expect(trigger.element().matches(':focus-visible')).toBe(false) + }, +) + +it('keeps focus in Preferences after the account menu finishes closing', async () => { + const settings = globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } + const animationsDisabled = settings.BASE_UI_ANIMATIONS_DISABLED + settings.BASE_UI_ANIMATIONS_DISABLED = false + try { + const screen = await renderAccount() + await screen + .getByRole('button', { name: 'Current User accountSettings.account.account' }) + .click() + await screen.getByRole('menuitem', { name: 'navigation.settings.preferences' }).click() + const input = screen.getByRole('textbox', { name: 'Preference' }) + await expect.element(input).toHaveFocus() + await expect.element(screen.getByRole('menu')).not.toBeInTheDocument() + await expect.element(input).toHaveFocus() + } finally { + settings.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled + } +}) + +it.each(['keyboard', 'pointer then keyboard'])( + 'keeps visible focus after %s navigation and Escape dismissal', + async (interaction) => { + const screen = await renderAccount() + const trigger = screen.getByRole('button', { + name: 'Current User accountSettings.account.account', + }) + if (interaction === 'keyboard') { + await screen.getByRole('button', { name: 'Before account' }).click() + await userEvent.keyboard('{Tab}{Enter}') + } else { + await trigger.click() + await userEvent.keyboard('{ArrowDown}') + } + await expect.element(screen.getByRole('menu')).toBeVisible() + await userEvent.keyboard('{Escape}') + await expect.element(trigger).toHaveFocus() + expect(trigger.element().matches(':focus-visible')).toBe(true) + expect(getComputedStyle(trigger.element()).boxShadow).not.toBe('none') + }, +) + +it('clears the keyboard focus ring when the same account menu is reopened and dismissed with the pointer', async () => { + const settings = globalThis as typeof globalThis & { BASE_UI_ANIMATIONS_DISABLED: boolean } + const animationsDisabled = settings.BASE_UI_ANIMATIONS_DISABLED + settings.BASE_UI_ANIMATIONS_DISABLED = false + try { + const screen = await renderAccount() + const trigger = screen.getByRole('button', { + name: 'Current User accountSettings.account.account', + }) + const menu = screen.getByRole('menu') + const dismissWithPointer = async () => { + await trigger.click() + await expect.element(menu).toBeVisible() + await screen + .getByRole('presentation') + .filter({ hasNotText: 'current@example.com' }) + .click({ position: { x: 10, y: 600 } }) + await expect.element(menu).not.toBeInTheDocument() + expect(trigger.element().matches(':focus-visible')).toBe(false) + } + + await dismissWithPointer() + + await screen.getByRole('button', { name: 'Before account' }).click() + await userEvent.keyboard('{Tab}{Enter}{ArrowDown}') + await expect.element(menu).toBeVisible() + await userEvent.keyboard('{Escape}') + await expect.element(menu).not.toBeInTheDocument() + await expect.element(trigger).toHaveFocus() + expect(trigger.element().matches(':focus-visible')).toBe(true) + + await dismissWithPointer() + } finally { + settings.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled + } +}) diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index d905a5c81494b4..e59d7005929b87 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -22,7 +22,7 @@ import { import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useTheme } from 'next-themes' import { useQueryState } from 'nuqs' -import { useSyncExternalStore } from 'react' +import { useRef, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import PremiumBadge from '@/app/components/base/premium-badge' import { @@ -213,6 +213,9 @@ function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) { } export default function AccountDropdown({ trigger }: AccountDropdownProps) { + const triggerRef = useRef(null) + const popupRef = useRef(null) + const keyboardNavigationRef = useRef(false) const isHydrating = useSyncExternalStore( subscribeHydrationState, getHydrationSnapshot, @@ -224,14 +227,58 @@ export default function AccountDropdown({ trigger }: AccountDropdownProps) { return (
- + { + if (open) + keyboardNavigationRef.current = + event.type.startsWith('key') || + (event.type === 'click' && 'detail' in event && event.detail === 0) + }} + > { + if (event.key !== 'Escape') keyboardNavigationRef.current = true + }} disabled={isHydrating} render={trigger({ ariaLabel: t(($) => $['account.account'], { ns: 'accountSettings' }), })} /> { + if (event.key !== 'Escape') keyboardNavigationRef.current = true + }} + finalFocus={(interactionType) => { + const canRestoreFocus = () => { + const activeElement = document.activeElement + return ( + activeElement === document.body || + activeElement === triggerRef.current || + !!popupRef.current?.contains(activeElement) + ) + } + if ( + interactionType === 'touch' || + interactionType === '' || + (interactionType === 'keyboard' && keyboardNavigationRef.current) + ) + return canRestoreFocus() + // Restore after dismissal, respecting focus moved into a destination dialog. + queueMicrotask(() => { + if (!canRestoreFocus()) return + const triggerElement = triggerRef.current + // Focusing an already focused element does not update its focus-visible state. + if ( + document.activeElement === triggerElement && + triggerElement?.matches(':focus-visible') + ) + triggerElement.blur() + triggerElement?.focus({ preventScroll: true, focusVisible: false }) + }) + return false + }} placement="top-start" sideOffset={6} alignOffset={4} diff --git a/web/app/components/header/account-setting/__tests__/constants.spec.ts b/web/app/components/header/account-setting/__tests__/constants.spec.ts index 028502d1401ee8..e7a1eb0a981117 100644 --- a/web/app/components/header/account-setting/__tests__/constants.spec.ts +++ b/web/app/components/header/account-setting/__tests__/constants.spec.ts @@ -7,6 +7,7 @@ import { describe('settingsQueryParser', () => { it.each([ ['roles-and-permissions', 'roles-and-permissions'], + ['ip-policies', 'ip-policies'], ['access-token', 'access-token'], ['preferences', 'preferences'], ['provider', 'provider'], diff --git a/web/app/components/header/account-setting/__tests__/settings-loading.spec.tsx b/web/app/components/header/account-setting/__tests__/settings-loading.spec.tsx new file mode 100644 index 00000000000000..219a7fa97d5efa --- /dev/null +++ b/web/app/components/header/account-setting/__tests__/settings-loading.spec.tsx @@ -0,0 +1,163 @@ +import type { Resource } from 'i18next' +import type { AccountSettingTab } from '../constants' +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { Suspense, useState } from 'react' +import { I18nClientProvider } from '@/app/components/provider/i18n' +import { seedNetworkAccessGroups } from '@/test/console/network-access' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { ACCOUNT_SETTING_TAB } from '../constants' +import AccountSetting from '../index' + +vi.unmock('react-i18next') +const mocks = vi.hoisted(() => ({ loadResource: vi.fn() })) +vi.mock('@/i18n/load-resource', () => ({ loadI18nResource: mocks.loadResource })) + +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + return createWorkspaceStateModuleMock(() => ({ + currentWorkspace: { id: 'workspace-1', name: 'Workspace', role: 'owner' }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: true, + })) +}) +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + return createPermissionStateModuleMock(() => ({ + workspacePermissionKeys: ['workspace.member.manage'], + })) +}) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.members.get': async () => ({ accounts: [] }), + 'workspaces.current.rbac.roles.get': async () => ({ + data: [], + pagination: { current_page: 1, total_pages: 1, total_count: 0, per_page: 20 }, + }), + }), + } +}) +vi.mock('@/hooks/use-breakpoints', () => ({ + MediaType: { mobile: 'mobile', tablet: 'tablet', pc: 'pc' }, + default: () => 'pc', +})) +vi.mock('@/app/components/billing/billing-page', () => ({ default: () => null })) +vi.mock('@/app/components/custom/custom-page', () => ({ default: () => null })) +vi.mock('../access-rules-page', () => ({ default: () => null })) +vi.mock('../permissions-page', () => ({ default: () => null })) +vi.mock('../preference-page', () => ({ default: () => null })) +vi.mock('../workflow-log-archives-page', () => ({ default: () => null })) + +const resources: Resource = { + 'en-US': { + appLog: {}, + billing: {}, + custom: {}, + accountSettings: {}, + common: { + loading: 'Loading', + 'operation.close': 'Close', + 'settings.ipPolicies': 'IP Policies', + 'settings.ipPolicyEmptyTitle': 'No IP policies yet', + }, + navigation: { + 'settings.settings': 'Settings', + 'settings.members': 'Members', + }, + workspaceMembers: { + 'members.invite': 'Invite members', + 'members.inviteTeamMember': 'Invite team member', + }, + }, +} + +function Settings() { + const [activeTab, setActiveTab] = useState(ACCOUNT_SETTING_TAB.MEMBERS) + return ( + {}} + /> + ) +} + +function renderSettings() { + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: true, groups: [] }) + return renderWithConsoleQuery( + + + Loading settings}> + + + + , + { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD', is_email_setup: true }, + features: { billing: { subscription: { plan: 'team' } } }, + }, + ) +} + +function deferNamespace(namespace: string) { + let finishLoading!: () => void + const pending = new Promise((resolve) => { + finishLoading = resolve + }) + mocks.loadResource.mockImplementation(async (_locale: string, requested: string) => { + if (requested === namespace) await pending + return { default: {} } + }) + return async () => { + await act(async () => { + finishLoading() + await pending + }) + } +} + +describe('Settings content loading', () => { + beforeEach(() => { + mocks.loadResource.mockReset() + }) + + it('keeps the dialog and navigation visible while a tab loads its translations', async () => { + const user = userEvent.setup() + const finishLoading = deferNamespace('deployments') + renderSettings() + await user.click(await screen.findByRole('button', { name: 'IP Policies' })) + + await waitFor(() => { + expect(mocks.loadResource).toHaveBeenCalledWith('en-US', 'deployments') + }) + expect(screen.getByRole('dialog', { name: 'Settings' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Members' })).toBeVisible() + expect(screen.queryByText('Loading settings')).not.toBeInTheDocument() + + await finishLoading() + expect(await screen.findByRole('heading', { name: 'No IP policies yet' })).toBeVisible() + }) + + it('keeps the Settings shell visible while opening a nested dialog for the first time', async () => { + const user = userEvent.setup() + const finishLoading = deferNamespace('permission') + renderSettings() + await user.click(await screen.findByRole('button', { name: 'Invite members' })) + + await waitFor(() => { + expect(mocks.loadResource).toHaveBeenCalledWith('en-US', 'permission') + }) + expect(screen.getByText('Settings', { selector: 'div' })).toBeVisible() + expect(screen.queryByText('Loading settings')).not.toBeInTheDocument() + + await finishLoading() + expect(await screen.findByRole('dialog', { name: 'Invite team member' })).toBeVisible() + }) +}) diff --git a/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx b/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx index 9efe4eaf133f5c..48f680f127cc67 100644 --- a/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/settings-modal.spec.tsx @@ -36,6 +36,11 @@ vi.mock('@/service/console', async (importOriginal) => { return { ...actual, consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'workspaces.current.networkAccessGroups.get': async () => ({ + tenant_id: 'workspace-1', + entitled: true, + groups: [], + }), 'workspaces.current.members.get': async () => ({ accounts: [] }), 'features.vectorSpace.get': async () => ({ size: 0, limit: 50, usage_unknown: false }), 'billing.invoices.get': async () => ({ url: 'https://billing.example.com' }), @@ -319,3 +324,42 @@ it('ignores an invalid settings destination', () => { renderSettings('unknown') expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + +it.each(['owner', 'admin', 'editor'] as const)( + 'opens IP Policies with the controls allowed for %s', + async (role) => { + renderSettings(TAB.IP_POLICIES, { currentWorkspace: { role } }) + const dialog = await findSettings() + expect( + await within(dialog).findByRole('heading', { name: 'common.settings.ipPolicyEmptyTitle' }), + ).toBeInTheDocument() + expect( + within(dialog).getByRole('button', { name: 'common.settings.ipPolicies' }), + ).toBeInTheDocument() + expect( + within(dialog).getByRole('heading', { name: 'common.settings.ipPolicies' }), + ).toBeInTheDocument() + if (role === 'editor') { + expect( + within(dialog).queryByRole('button', { name: 'common.settings.ipPolicyEmptyCreate' }), + ).not.toBeInTheDocument() + } else { + expect( + within(dialog).getByRole('button', { name: 'common.settings.ipPolicyEmptyCreate' }), + ).toBeInTheDocument() + } + }, +) + +it.each(['normal', 'dataset_operator'] as const)( + 'hides IP Policies and rejects its direct destination for %s', + async (role) => { + renderSettings(TAB.IP_POLICIES, { currentWorkspace: { role } }) + const dialog = await findSettings() + expect(await expectMembers()).toBeInTheDocument() + expect( + within(dialog).queryByRole('button', { name: 'common.settings.ipPolicies' }), + ).not.toBeInTheDocument() + expect(within(dialog).queryByText('common.settings.ipPolicyEmptyTitle')).not.toBeInTheDocument() + }, +) diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index 854471fa7f8fba..6950fe6cd4c5aa 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -1,5 +1,6 @@ export const ACCOUNT_SETTING_TAB = { MEMBERS: 'members', + IP_POLICIES: 'ip-policies', ROLES_AND_PERMISSIONS: 'roles-and-permissions', PERMISSION_SET: 'permission-set', ACCESS_TOKEN: 'access-token', @@ -13,6 +14,7 @@ export type AccountSettingTab = (typeof ACCOUNT_SETTING_TAB)[keyof typeof ACCOUN export const ACCOUNT_SETTING_TAB_VALUES = [ ACCOUNT_SETTING_TAB.MEMBERS, + ACCOUNT_SETTING_TAB.IP_POLICIES, ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS, ACCOUNT_SETTING_TAB.PERMISSION_SET, ACCOUNT_SETTING_TAB.ACCESS_TOKEN, diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 5565c413ea9f8f..2c728b048fa616 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -11,8 +11,9 @@ import { } from '@langgenius/dify-ui/scroll-area' import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { useRef } from 'react' +import { Suspense, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import BillingPage from '@/app/components/billing/billing-page' import CustomPage from '@/app/components/custom/custom-page' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' @@ -24,11 +25,13 @@ import { isCurrentWorkspaceOwnerAtom, } from '@/context/workspace-state' import { env } from '@/env' +import { canReadNetworkAccessAtom } from '@/features/network-access/permissions' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { consoleQuery } from '@/service/console' import { hasPermission } from '@/utils/permission' import AccessRulesPage from './access-rules-page' +import IpPoliciesPage from './ip-policies-page' import MembersPage from './members-page' import PermissionsPage from './permissions-page' import PreferencePage from './preference-page' @@ -59,7 +62,14 @@ export default function AccountSetting({ activeTab, onTabChangeAction, }: IAccountSettingProps) { - const { t } = useTranslation(['appLog', 'billing', 'custom', 'navigation', 'accountSettings']) + const { t } = useTranslation([ + 'appLog', + 'billing', + 'common', + 'custom', + 'navigation', + 'accountSettings', + ]) const { data: enableReplaceWebAppLogo } = useQuery( consoleQuery.features.get.queryOptions({ select: (features) => features.can_replace_logo, @@ -78,7 +88,10 @@ export default function AccountSetting({ systemFeatures.deployment_edition === 'CLOUD' && !isCurrentWorkspaceDatasetOperator const canViewWorkflowLogArchives = systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager + const canViewIpPolicies = useAtomValue(canReadNetworkAccessAtom) const activeMenu = (() => { + if (activeTab === ACCOUNT_SETTING_TAB.IP_POLICIES && !canViewIpPolicies) + return ACCOUNT_SETTING_TAB.MEMBERS if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling) return ACCOUNT_SETTING_TAB.PREFERENCES if (activeTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && !canViewWorkflowLogArchives) @@ -102,6 +115,12 @@ export default function AccountSetting({ icon: , activeIcon: , }, + { + key: ACCOUNT_SETTING_TAB.IP_POLICIES, + name: t(($) => $['settings.ipPolicies'], { ns: 'common' }), + icon: , + activeIcon: , + }, { key: ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS, name: t(($) => $['settings.rolesAndPermissions'], { ns: 'navigation' }), @@ -156,6 +175,7 @@ export default function AccountSetting({ const visibleTabs: AccountSettingTab[] = [] visibleTabs.push(ACCOUNT_SETTING_TAB.MEMBERS) + if (canViewIpPolicies) visibleTabs.push(ACCOUNT_SETTING_TAB.IP_POLICIES) if (canManageWorkspaceRoles) { visibleTabs.push(ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS) @@ -258,29 +278,35 @@ export default function AccountSetting({ style={{ minWidth: 0 }} className="min-h-full w-full max-w-full pb-4" > -
-
- {activeItem?.title ?? activeItem?.name} - {activeItem?.description && ( -
- {activeItem?.description} -
- )} + {activeMenu !== ACCOUNT_SETTING_TAB.IP_POLICIES && ( +
+
+ {activeItem?.title ?? activeItem?.name} + {activeItem?.description && ( +
+ {activeItem?.description} +
+ )} +
-
+ )}
- {activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && } - {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && ( - - )} - {activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && } - {activeMenu === ACCOUNT_SETTING_TAB.ACCESS_TOKEN && } - {activeMenu === ACCOUNT_SETTING_TAB.BILLING && } - {activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && ( - - )} - {activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && } - {activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && } + {/* First-use translations and queries must not suspend the dialog shell. */} + }> + {activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && } + {activeMenu === ACCOUNT_SETTING_TAB.IP_POLICIES && } + {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && ( + + )} + {activeMenu === ACCOUNT_SETTING_TAB.PERMISSION_SET && } + {activeMenu === ACCOUNT_SETTING_TAB.ACCESS_TOKEN && } + {activeMenu === ACCOUNT_SETTING_TAB.BILLING && } + {activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && ( + + )} + {activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && } + {activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && } +
diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx new file mode 100644 index 00000000000000..dbee54d30f08ef --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/index.spec.tsx @@ -0,0 +1,1140 @@ +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createInstance } from 'i18next' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { initReactI18next } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' +import commonTranslations from '@/i18n/locales/en-US/common.json' +import deploymentTranslations from '@/i18n/locales/en-US/deployments.json' +import { seedCurrentWorkspaceQuery } from '@/test/console/current-workspace' +import { + createNetworkAccessGroupFixture, + seedNetworkAccessGroups, +} from '@/test/console/network-access' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import IpPoliciesPage from '..' + +vi.unmock('react-i18next') +vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() })) + +describe('IpPoliciesPage', () => { + beforeEach(async () => { + vi.clearAllMocks() + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ client_ip: '203.0.113.42' }), + ) + await createInstance() + .use(initReactI18next) + .init({ + lng: 'en-US', + fallbackLng: 'en-US', + defaultNS: 'common', + keySeparator: false, + interpolation: { escapeValue: false }, + resources: { 'en-US': { common: commonTranslations, deployments: deploymentTranslations } }, + }) + }) + + it.each([true, false])( + 'lets an editor read the complete policy without management actions (entitled: %s)', + async (entitled) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled, + groups: [ + createNetworkAccessGroupFixture({ + allowed_cidrs: ['203.0.113.42/32', '198.51.100.0/24', '2001:db8::/32'], + used_by_count: 1, + app_ids: ['app-a'], + apps: [ + { + id: 'app-a', + name: 'Support Bot', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'editor' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'More actions for Internal Network' }), + ).not.toBeInTheDocument() + await user.tab() + expect(screen.getByRole('button', { name: 'Internal Network' })).toHaveFocus() + await user.keyboard('{Enter}') + + const dialog = screen.getByRole('dialog', { name: 'Internal Network' }) + expect(dialog).toHaveAccessibleDescription( + 'Only the workspace owner and admins can edit IP policies.', + ) + expect(within(dialog).getAllByText('Internal Network')).toHaveLength(1) + expect(within(dialog).queryByText('Name')).not.toBeInTheDocument() + expect(trackEvent).toHaveBeenCalledWith('ip_policy_interaction', { + action: 'settings_tab_viewed', + }) + expect(trackEvent).toHaveBeenCalledWith('ip_policy_interaction', { + action: 'form_opened', + mode: 'view', + source: 'list_row', + policy_id: 'group-1', + }) + expect(within(dialog).getByText('203.0.113.42/32')).toBeInTheDocument() + expect(within(dialog).getByText('198.51.100.0/24')).toBeInTheDocument() + expect(within(dialog).getByText('2001:db8::/32')).toBeInTheDocument() + expect(within(dialog).getByRole('link', { name: 'Support Bot' })).toBeInTheDocument() + expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument() + expect( + within(dialog).queryByRole('button', { name: /Create|Save|Delete|Add/ }), + ).not.toBeInTheDocument() + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + + it.each(['edit', 'delete'] as const)( + 'allows an entitled administrator to %s a policy', + async (action) => { + const user = userEvent.setup() + const group = createNetworkAccessGroupFixture({ version: 7 }) + const requests: Array<{ method: string; url: string; body: unknown }> = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + const body = await request.text() + requests.push({ + method: request.method, + url: request.url, + body: body ? JSON.parse(body) : null, + }) + return request.method === 'DELETE' + ? new Response(null, { status: 204 }) + : new Response(JSON.stringify({ group, entitled: true }), { + headers: { 'content-type': 'application/json' }, + }) + }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [group] }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'admin' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click( + screen.getByRole('menuitem', { name: action === 'edit' ? 'Edit' : 'Delete' }), + ) + if (action === 'edit') { + await user.clear(screen.getByRole('textbox', { name: 'Name' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Office Network') + await user.click(screen.getByRole('button', { name: 'Save' })) + } else { + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Delete' }), + ) + } + + await waitFor(() => + expect(requests).toContainEqual( + action === 'edit' + ? { + method: 'PUT', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups/group-1', + body: { + name: 'Office Network', + description: '', + allowed_cidrs: group.allowed_cidrs, + expected_version: 7, + }, + } + : { + method: 'DELETE', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups/group-1?expected_version=7', + body: null, + }, + ), + ) + }, + ) + + it('reloads a conflicting policy from the server before another save', async () => { + const user = userEvent.setup() + const original = createNetworkAccessGroupFixture({ version: 1 }) + const remote = createNetworkAccessGroupFixture({ name: 'Allow-Remote', version: 2 }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [original] }) + const requests: Request[] = [] + const putBodies: unknown[] = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + requests.push(request) + if (request.url.endsWith('/current-ip')) return Response.json({ client_ip: '203.0.113.42' }) + if (request.method === 'GET') + return Response.json({ tenant_id: 'workspace-1', entitled: true, groups: [remote] }) + putBodies.push(JSON.parse(await request.clone().text())) + if (requests.filter((item) => item.method === 'PUT').length === 1) + return Response.json({ code: 'network_access_conflict' }, { status: 409 }) + return Response.json({ group: remote, entitled: true }) + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + await user.clear(screen.getByRole('textbox', { name: 'Name' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Allow-Local') + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Allow-Remote'), + ) + expect( + requests.filter( + (request) => request.method === 'GET' && !request.url.endsWith('/current-ip'), + ), + ).toHaveLength(1) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Allow-Remote' })) + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Allow-Remote') + await user.clear(screen.getByRole('textbox', { name: 'Name' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Confirmed') + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(requests.filter((request) => request.method === 'PUT')).toHaveLength(2), + ) + expect(putBodies[1]).toMatchObject({ + name: 'Confirmed', + expected_version: 2, + }) + }) + + it('accumulates all validation errors through a conflict retry and resets them on reopen', async () => { + const user = userEvent.setup() + const group = createNetworkAccessGroupFixture({ allowed_cidrs: ['10.0.0.0/8'] }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [group] }) + let saves = 0 + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.url.endsWith('/current-ip')) return Response.json({ client_ip: '203.0.113.42' }) + if (request.method === 'PUT') { + saves += 1 + return saves === 1 + ? Response.json({ code: 'network_access_conflict' }, { status: 409 }) + : Response.json({ group, entitled: true }) + } + return Response.json({ + tenant_id: 'workspace-1', + entitled: true, + groups: [{ ...group, version: 2 }], + }) + }) + renderWithConsoleQuery( + + + , + { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + const entry = screen.getByPlaceholderText('10.0.0.0/8') + for (const invalid of [ + '256.1.1.1', + '01.1.1.1', + 'gg::1', + '10.0.0.1/x', + '10.0.0.1/33', + '10.0.0.1/1/2', + 'invalid', + '256.1.1.1', + ]) { + await user.clear(entry) + await user.paste(invalid) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + } + await user.clear(entry) + await user.paste('10.0.0.0/8') + const validationErrors = [ + 'octet_out_of_range', + 'leading_zero', + 'invalid_ipv6', + 'prefix_not_number', + 'prefix_out_of_range', + 'multiple_slash', + 'unsupported_format', + ] + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(trackEvent).toHaveBeenCalledWith('ip_policy_save', { + mode: 'edit', + source: 'list_row', + policy_id: 'group-1', + entry_count: 1, + validation_error_types: validationErrors, + result: 'failed', + fail_reason: 'other', + }), + ) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Save' })).not.toHaveAttribute( + 'aria-disabled', + 'true', + ), + ) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(trackEvent).toHaveBeenCalledWith('ip_policy_save', { + mode: 'edit', + source: 'list_row', + policy_id: 'group-1', + entry_count: 1, + validation_error_types: validationErrors, + result: 'success', + }), + ) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(trackEvent).toHaveBeenCalledWith('ip_policy_save', { + mode: 'edit', + source: 'list_row', + policy_id: 'group-1', + entry_count: 1, + validation_error_types: [], + result: 'success', + }), + ) + expect( + vi.mocked(trackEvent).mock.calls.filter(([event]) => event === 'ip_policy_save'), + ).toHaveLength(3) + expect( + vi.mocked(trackEvent).mock.calls.filter(([, props]) => props?.action === 'form_opened'), + ).toHaveLength(2) + }) + + it('offers a retry when conflict recovery cannot reload the policy', async () => { + const user = userEvent.setup() + const original = createNetworkAccessGroupFixture({ version: 1 }) + const remote = createNetworkAccessGroupFixture({ name: 'Allow-Remote', version: 2 }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [original] }) + let getCount = 0 + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + if (request.url.endsWith('/current-ip')) return Response.json({ client_ip: '203.0.113.42' }) + if (request.method === 'GET') { + getCount += 1 + return getCount === 1 + ? Response.json({ message: 'Unavailable' }, { status: 500 }) + : Response.json({ tenant_id: 'workspace-1', entitled: true, groups: [remote] }) + } + return Response.json({ code: 'network_access_conflict' }, { status: 409 }) + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load') + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'Try Again' })) + await waitFor(() => + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Allow-Remote'), + ) + expect(getCount).toBe(2) + }) + + it('does not replace another edit session with a late conflict response', async () => { + const user = userEvent.setup() + const original = createNetworkAccessGroupFixture({ version: 1 }) + const other = createNetworkAccessGroupFixture({ id: 'group-2', name: 'Other Policy' }) + const remote = createNetworkAccessGroupFixture({ name: 'Allow-Remote', version: 2 }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [original, other] }) + let resolveReload!: (response: Response) => void + const reload = new Promise((resolve) => { + resolveReload = resolve + }) + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + if (request.url.endsWith('/current-ip')) return Response.json({ client_ip: '203.0.113.42' }) + return request.method === 'GET' + ? reload + : Response.json({ code: 'network_access_conflict' }, { status: 409 }) + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true'), + ) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Other Policy' })) + await act(async () => { + resolveReload( + Response.json({ tenant_id: 'workspace-1', entitled: true, groups: [remote, other] }), + ) + }) + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Other Policy') + }) + + it.each(['create_empty', 'create', 'edit', 'delete'] as const)( + 'stops %s when manager permission is revoked', + async (action) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + groups: action === 'create_empty' ? [] : [createNetworkAccessGroupFixture()], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'admin' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + if (action === 'create' || action === 'create_empty') { + await user.click( + screen.getByRole('button', { + name: action === 'create_empty' ? 'Create an IP policy' : 'Add', + }), + ) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'New policy') + await user.type(screen.getByPlaceholderText('10.0.0.0/8'), '10.0.0.0/8') + } else { + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click( + screen.getByRole('menuitem', { name: action === 'edit' ? 'Edit' : 'Delete' }), + ) + if (action === 'edit') + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' draft') + } + + await act(async () => { + seedCurrentWorkspaceQuery(queryClient, { role: 'editor' }) + }) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Create an IP policy' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /^(Create|Save|Delete)$/ }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + if (action === 'edit') { + expect(screen.getByRole('dialog', { name: 'Internal Network' })).toBeInTheDocument() + expect(screen.queryByText('Internal Network draft')).not.toBeInTheDocument() + } else { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + } + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.filter(([input, init]) => new Request(input, init).method !== 'GET'), + ).toHaveLength(0) + }, + ) + + it.each(['normal', 'dataset_operator'] as const)( + 'does not query or render policies for %s', + (role) => { + const queryClient = createConsoleQueryClient() + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + expect(screen.queryByRole('heading', { name: 'IP Policies' })).not.toBeInTheDocument() + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + + it.each(['owner', 'admin'] as const)( + 'opens a fresh empty-state form for %s on each visit', + async (role) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: true, groups: [] }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + expect( + screen.getByRole('heading', { name: 'No IP policies in this workspace yet' }), + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + await user.tab() + expect(screen.getByRole('button', { name: 'Create an IP policy' })).toHaveFocus() + await user.keyboard('{Enter}') + expect(screen.getByRole('heading', { name: 'New IP Policy' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + expect(vi.mocked(trackEvent).mock.calls).toEqual([ + ['ip_policy_interaction', { action: 'settings_tab_viewed' }], + [ + 'ip_policy_interaction', + { action: 'form_opened', mode: 'create', source: 'list_empty_state' }, + ], + ]) + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Unsaved policy') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('') + expect(vi.mocked(trackEvent).mock.calls).toEqual([ + ['ip_policy_interaction', { action: 'settings_tab_viewed' }], + [ + 'ip_policy_interaction', + { action: 'form_opened', mode: 'create', source: 'list_empty_state' }, + ], + [ + 'ip_policy_interaction', + { action: 'form_opened', mode: 'create', source: 'list_empty_state' }, + ], + ]) + }, + ) + + it.each(['list_empty_state', 'list_add_button'] as const)( + 'creates a policy from %s and refreshes the list', + async (source) => { + const user = userEvent.setup() + const initialGroups = source === 'list_add_button' ? [createNetworkAccessGroupFixture()] : [] + const created = createNetworkAccessGroupFixture({ id: 'new-group', name: 'Office' }) + const posted: Array<{ method: string; url: string; body: unknown }> = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + const bodyText = + request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text() + posted.push({ + method: request.method, + url: request.url, + body: bodyText ? JSON.parse(bodyText) : null, + }) + if (request.method === 'POST') + return Response.json({ group: created, entitled: true }, { status: 201 }) + if (new URL(request.url).pathname.endsWith('/network-access-groups')) + return Response.json({ + tenant_id: 'workspace-1', + entitled: true, + groups: [...initialGroups, created], + }) + return Response.json({ client_ip: '203.0.113.42' }) + }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: true, groups: initialGroups }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click( + screen.getByRole('button', { + name: source === 'list_empty_state' ? 'Create an IP policy' : 'Add', + }), + ) + await user.type(screen.getByPlaceholderText('e.g. Internal Network'), 'Office') + await user.click(screen.getByPlaceholderText('10.0.0.0/8')) + await user.paste('10.0.0.0/8') + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled() + await user.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => { + expect(posted).toContainEqual({ + method: 'POST', + url: 'http://localhost:5001/console/api/workspaces/current/network-access-groups', + body: { + name: 'Office', + description: '', + allowed_cidrs: ['10.0.0.0/8'], + }, + }) + }) + expect(await screen.findByRole('button', { name: 'Office' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'No IP policies in this workspace yet' }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create an IP policy' })).not.toBeInTheDocument() + expect(trackEvent).toHaveBeenCalledWith('ip_policy_interaction', { + action: 'form_opened', + mode: 'create', + source, + }) + await waitFor(() => + expect(trackEvent).toHaveBeenCalledWith('ip_policy_save', { + mode: 'create', + source, + result: 'success', + policy_id: created.id, + entry_count: 1, + validation_error_types: [], + }), + ) + }, + ) + + it('keeps the empty-state source and validation history through a failed creation and retry', async () => { + const user = userEvent.setup() + const created = createNetworkAccessGroupFixture({ name: 'Office' }) + let attempts = 0 + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + if (request.method === 'POST') { + attempts += 1 + return attempts === 1 + ? Response.json({ message: 'Unavailable' }, { status: 503 }) + : Response.json({ group: created, entitled: true }, { status: 201 }) + } + if (new URL(request.url).pathname.endsWith('/network-access-groups')) + return Response.json({ tenant_id: 'workspace-1', entitled: true, groups: [created] }) + return Response.json({ client_ip: '203.0.113.42' }) + }) + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Office') + const entry = screen.getByPlaceholderText('10.0.0.0/8') + await user.click(entry) + await user.paste('999.0.0.1') + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled() + await user.clear(entry) + await user.paste('10.0.0.0/8') + await user.click(screen.getByRole('button', { name: 'Create' })) + + const analytics = { + mode: 'create', + source: 'list_empty_state', + entry_count: 1, + validation_error_types: ['octet_out_of_range'], + } + await waitFor(() => + expect(trackEvent).toHaveBeenCalledWith('ip_policy_save', { + ...analytics, + result: 'failed', + fail_reason: 'network', + }), + ) + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Office') + expect(entry).toHaveValue('10.0.0.0/8') + await user.click(screen.getByRole('button', { name: 'Create' })) + await waitFor(() => + expect( + vi.mocked(trackEvent).mock.calls.filter(([event]) => event === 'ip_policy_save'), + ).toEqual([ + ['ip_policy_save', { ...analytics, result: 'failed', fail_reason: 'network' }], + ['ip_policy_save', { ...analytics, result: 'success', policy_id: created.id }], + ]), + ) + expect( + vi + .mocked(trackEvent) + .mock.calls.filter(([, properties]) => properties?.action === 'form_opened'), + ).toHaveLength(1) + }) + + it('keeps empty-state creation unavailable to editors', () => { + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: 'editor' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + + expect( + screen.getByRole('heading', { name: 'No IP policies in this workspace yet' }), + ).toBeInTheDocument() + expect(screen.getByText('Ask a workspace owner or admin to create one.')).toBeInTheDocument() + expect( + screen.queryByText('A policy is the list of IP addresses allowed in.'), + ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create an IP policy' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument() + expect(trackEvent).toHaveBeenCalledExactlyOnceWith('ip_policy_interaction', { + action: 'settings_tab_viewed', + }) + }) + + it('opens pricing without reporting a form when the empty workspace is not entitled', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { entitled: false }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'Create an IP policy' })) + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()) + expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(trackEvent).toHaveBeenCalledExactlyOnceWith('ip_policy_interaction', { + action: 'settings_tab_viewed', + }) + }) + + it('does not show the empty state while policies are loading or when the request fails', async () => { + let resolveGroups!: (response: Response) => void + vi.mocked(globalThis.fetch).mockReturnValue( + new Promise((resolve) => { + resolveGroups = resolve + }), + ) + renderWithConsoleQuery( + + + , + { systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + expect(screen.getByRole('status')).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'No IP policies in this workspace yet' }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create an IP policy' })).not.toBeInTheDocument() + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()) + await act(async () => { + resolveGroups(Response.json({ message: 'Unavailable' }, { status: 503 })) + }) + expect(await screen.findByText(deploymentTranslations['common.loadFailed'])).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'No IP policies in this workspace yet' }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Create an IP policy' })).not.toBeInTheDocument() + }) + + it('lists existing policies and opens edit from the row', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled: true, + groups: [createNetworkAccessGroupFixture()], + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + expect(screen.getByText('Internal Network')).toBeInTheDocument() + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('IP Entries')).toBeInTheDocument() + expect(screen.getByText('Used by')).toBeInTheDocument() + expect(screen.getByText('2')).toBeInTheDocument() + expect(screen.getByText('0 apps')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Edit' })) + expect(screen.getByRole('heading', { name: 'Edit IP Policy' })).toBeInTheDocument() + expect(screen.getByDisplayValue('Internal Network')).toBeInTheDocument() + }) + + it('warns that editing a used policy updates every referencing app', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled: true, + groups: [ + createNetworkAccessGroupFixture({ + used_by_count: 2, + app_ids: ['app-a', 'app-b'], + apps: [ + { + id: 'app-a', + name: 'Support Bot', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + { + id: 'app-b', + name: 'Helpdesk', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Edit' })) + expect(within(screen.getByRole('dialog')).getByText('Used by')).toBeInTheDocument() + expect( + screen.getByText('Changes take effect immediately wherever this policy is applied.'), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute( + 'href', + '/app/app-a/overview', + ) + expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute('target', '_blank') + }) + + it('lets the user delete a referenced policy after confirming the impact', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + entitled: true, + groups: [ + createNetworkAccessGroupFixture({ + used_by_count: 2, + app_ids: ['app-a', 'app-b'], + apps: [ + { + id: 'app-a', + name: 'Support Bot', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + { + id: 'app-b', + name: 'Helpdesk', + mode: 'chat', + icon: null, + icon_type: null, + icon_background: null, + }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete' })) + const dialog = screen.getByRole('alertdialog', { name: 'Delete “Internal Network”?' }) + expect(dialog).toHaveAccessibleDescription( + 'Apps using this policy will become accessible from any IP address. This can’t be undone.', + ) + expect(within(dialog).getByText('Still used by 2 apps')).toBeInTheDocument() + expect(globalThis.fetch).not.toHaveBeenCalled() + expect(within(dialog).getByRole('button', { name: 'Delete' })).toBeEnabled() + expect(within(dialog).getByRole('link', { name: 'Support Bot' })).toHaveAttribute( + 'href', + '/app/app-a/overview', + ) + }) + + it.each(['Cancel', 'Close'])( + 'dismisses deletion through %s without deleting the policy', + async (button) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [createNetworkAccessGroupFixture()] }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete' })) + const dialog = screen.getByRole('alertdialog', { name: 'Delete “Internal Network”?' }) + expect(dialog).toHaveAccessibleDescription('This can’t be undone.') + expect(within(dialog).queryByText(/Still used by/)).not.toBeInTheDocument() + await user.click(within(dialog).getByRole('button', { name: button })) + + expect( + vi + .mocked(trackEvent) + .mock.calls.filter(([, props]) => String(props?.action).startsWith('delete_')), + ).toEqual([ + ['ip_policy_interaction', { action: 'delete_attempted', policy_id: 'group-1' }], + ...(button === 'Cancel' + ? [['ip_policy_interaction', { action: 'delete_cancelled', policy_id: 'group-1' }]] + : []), + ]) + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Internal Network' })).toBeInTheDocument() + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + + it.each([true, false])( + 'waits for deletion and only closes on success (success: %s)', + async (succeeds) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + const group = createNetworkAccessGroupFixture({ version: 7, used_by_count: 2 }) + seedNetworkAccessGroups(queryClient, { groups: [group] }) + let resolveDeletion!: (response: Response) => void + const deletion = new Promise((resolve) => { + resolveDeletion = resolve + }) + const deleteRequests: Request[] = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + if (request.method === 'DELETE') { + deleteRequests.push(request) + return deletion + } + return new Response(JSON.stringify({ groups: [], entitled: true }), { + headers: { 'content-type': 'application/json' }, + }) + }) + renderWithConsoleQuery( + + + , + { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, + ) + + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete' })) + const dialog = screen.getByRole('alertdialog', { name: 'Delete “Internal Network”?' }) + const deleteButton = within(dialog).getByRole('button', { name: 'Delete' }) + await user.click(deleteButton) + + await waitFor(() => expect(deleteButton).toHaveAttribute('aria-disabled', 'true')) + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled() + expect(within(dialog).getByRole('button', { name: 'Close' })).toBeDisabled() + await user.click(deleteButton) + await user.keyboard('{Escape}') + expect(dialog).toBeInTheDocument() + expect(deleteRequests).toHaveLength(1) + expect(deleteRequests[0]?.url).toContain('group-1?expected_version=7') + expect( + vi + .mocked(trackEvent) + .mock.calls.filter(([, props]) => String(props?.action).startsWith('delete_')), + ).toEqual([ + ['ip_policy_interaction', { action: 'delete_attempted', policy_id: 'group-1' }], + [ + 'ip_policy_interaction', + { action: 'delete_confirmed', policy_id: 'group-1', referenced_app_count: 2 }, + ], + ]) + + await act(async () => { + resolveDeletion( + succeeds + ? new Response(null, { status: 204 }) + : new Response(JSON.stringify({ message: 'Unable to delete policy' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ) + }) + + if (succeeds) { + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Internal Network' })).not.toBeInTheDocument() + } else { + await waitFor(() => expect(deleteButton).not.toHaveAttribute('aria-disabled', 'true')) + expect(dialog).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeEnabled() + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Internal Network' })).toBeInTheDocument() + } + }, + ) + + it.each(['owner', 'admin'] as const)( + 'opens edit directly from the policy row for %s', + async (role) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { groups: [createNetworkAccessGroupFixture()] }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + expect(screen.getByRole('dialog', { name: 'Edit IP Policy' })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Internal Network') + await user.keyboard('{Escape}') + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }, + ) + + it.each([0, 1])( + 'shows total references under Used by even when only %s apps enforce the policy', + (enforcingCount) => { + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + groups: [ + createNetworkAccessGroupFixture({ used_by_count: 2, enforcing_count: enforcingCount }), + ], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + expect(screen.getByText('Used by')).toBeInTheDocument() + expect( + within(screen.getByRole('button', { name: 'Internal Network' })).getByText('2 apps'), + ).toBeInTheDocument() + expect(screen.queryByText('Enforcing')).not.toBeInTheDocument() + }, + ) + + it.each(['view', 'edit', 'delete'] as const)( + 'links agent and regular app references correctly in %s', + async (mode) => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + seedNetworkAccessGroups(queryClient, { + groups: [ + createNetworkAccessGroupFixture({ + used_by_count: 2, + enforcing_count: 1, + apps: [ + { + id: 'backing-app', + bound_agent_id: 'agent-1', + name: 'New Agent', + mode: 'workflow', + icon: null, + icon_type: null, + icon_background: null, + }, + { + id: 'app-2', + name: 'Workflow', + mode: 'workflow', + icon: null, + icon_type: null, + icon_background: null, + }, + ], + }), + ], + }) + renderWithConsoleQuery( + + + , + { + queryClient, + currentWorkspace: { role: mode === 'view' ? 'editor' : 'owner' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }, + ) + if (mode === 'delete') { + await user.click(screen.getByRole('button', { name: 'More actions for Internal Network' })) + await user.click(screen.getByRole('menuitem', { name: 'Delete' })) + expect(screen.getByText('Still used by 2 apps')).toBeInTheDocument() + } else { + await user.click(screen.getByRole('button', { name: 'Internal Network' })) + } + expect(screen.getByRole('link', { name: 'New Agent' })).toHaveAttribute( + 'href', + '/agents/agent-1/configure', + ) + expect(screen.getByRole('link', { name: 'New Agent' })).toHaveAttribute('target', '_blank') + expect(screen.getByRole('link', { name: 'Workflow' })).toHaveAttribute( + 'href', + '/app/app-2/overview', + ) + }, + ) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx new file mode 100644 index 00000000000000..4d8b2c16c5c64a --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/__tests__/policy-item.browser.spec.tsx @@ -0,0 +1,78 @@ +import { userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { createNetworkAccessGroupFixture } from '@/test/console/network-access' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { PolicyItem } from '../policy-item' + +vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn() })) + +async function renderPolicy(canMutate = true) { + const group = createNetworkAccessGroupFixture() + const onView = vi.fn() + const onEdit = vi.fn() + const { wrapper: ConsoleProvider } = createConsoleQueryWrapper() + const screen = await render( + + +
+ +
+
, + ) + return { screen, group, onView, onEdit } +} + +// Real hit testing catches the row padding that unit clicks on the name button do not reach. +it.each([true, false])( + 'opens the policy from padding on every side of the row (can manage: %s)', + async (canMutate) => { + const { screen, group, onView, onEdit } = await renderPolicy(canMutate) + const row = screen.getByRole('region', { name: 'IP policy row' }) + const { width, height } = row.element().getBoundingClientRect() + const positions = [ + { x: width / 2, y: 2 }, + { x: width / 2, y: height - 2 }, + { x: 2, y: height / 2 }, + { x: width - 2, y: height / 2 }, + ] + if (!canMutate) positions.push({ x: width - 20, y: height / 2 }) + + for (const [index, position] of positions.entries()) { + await row.click({ position }) + expect(onView).toHaveBeenCalledTimes(index + 1) + expect(onView).toHaveBeenLastCalledWith(group) + } + expect(onEdit).not.toHaveBeenCalled() + }, +) + +it('keeps the row and its separate actions reachable with pointer and keyboard', async () => { + const { screen, group, onView, onEdit } = await renderPolicy() + const policy = screen.getByRole('button', { name: group.name }) + const more = screen.getByRole('button', { name: /common.operation.moreActionsFor/ }) + + await more.click() + await screen.getByRole('menuitem', { name: 'common.operation.edit' }).click() + expect(onEdit).toHaveBeenCalledExactlyOnceWith(group) + expect(onView).not.toHaveBeenCalled() + + await more.click() + await screen.getByRole('menuitem', { name: 'common.operation.delete' }).click() + const confirmation = screen.getByRole('alertdialog') + await expect.element(confirmation).toBeVisible() + expect(onView).not.toHaveBeenCalled() + await confirmation.getByRole('button', { name: 'common.operation.cancel' }).click() + await expect.element(confirmation).not.toBeInTheDocument() + + await screen.getByRole('button', { name: 'Before policy' }).click() + await userEvent.tab() + await expect.element(policy).toHaveFocus() + expect(policy.element().matches(':focus-visible')).toBe(true) + await userEvent.keyboard('{Enter}') + expect(onView).toHaveBeenCalledExactlyOnceWith(group) + await userEvent.tab() + await expect.element(more).toHaveFocus() + await userEvent.keyboard('{Enter}') + await expect.element(screen.getByRole('menu')).toBeVisible() + expect(onView).toHaveBeenCalledTimes(1) +}) diff --git a/web/app/components/header/account-setting/ip-policies-page/index.tsx b/web/app/components/header/account-setting/ip-policies-page/index.tsx new file mode 100644 index 00000000000000..e01e6c191bc3ab --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/index.tsx @@ -0,0 +1,358 @@ +'use client' + +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { IpPolicySource } from '@/features/network-access/analytics' +import type { IpPolicyFormSession } from '@/features/network-access/policy-editor/policy-form-analytics' +import { Button } from '@langgenius/dify-ui/button' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import Image from 'next/image' +import { useQueryState } from 'nuqs' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' +import { + pricingQueryParamName, + pricingQueryParser, +} from '@/app/components/billing/pricing/query-params' +import { currentWorkspaceAtom } from '@/context/workspace-state' +import { getIpPolicyFailReason, trackNetworkAccessEvent } from '@/features/network-access/analytics' +import { getNetworkAccessErrorStatus } from '@/features/network-access/errors' +import { + canManageNetworkAccessPoliciesAtom, + canReadNetworkAccessAtom, +} from '@/features/network-access/permissions' +import { IpPolicyDialog } from '@/features/network-access/policy-editor' +import { startIpPolicyForm } from '@/features/network-access/policy-editor/policy-form-analytics' +import { consoleQuery } from '@/service/console' +import { + policyActionsColClassName, + policyIpEntriesColClassName, + PolicyItem, + policyNameColClassName, + policyRowClassName, + policyUpdatedColClassName, + policyUsedByColClassName, +} from './policy-item' + +type DialogRequest = + | { mode: 'create' } + | { mode: 'edit' | 'view'; group: NetworkAccessGroupResponse } + | null +type DialogState = (NonNullable & { analytics: IpPolicyFormSession }) | null + +function IpPoliciesListSkeleton() { + const { t } = useTranslation(['common', 'deployments']) + + return ( +
$.loading, { ns: 'common' })}> + {Array.from({ length: 2 }, (_, index) => ( +
+ + + + + + + + +
+
+ ))} +
+ ) +} + +export default function IpPoliciesPage() { + const { t } = useTranslation(['common', 'deployments']) + const queryClient = useQueryClient() + const { plan } = useAtomValue(currentWorkspaceAtom) + const canReadPolicies = useAtomValue(canReadNetworkAccessAtom) + const canManagePolicies = useAtomValue(canManageNetworkAccessPoliciesAtom) + const [_pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) + const groupsQuery = consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ + enabled: canReadPolicies, + retry: false, + }) + const { data, isPending, isError } = useQuery(groupsQuery) + const createGroup = useMutation( + consoleQuery.workspaces.current.networkAccessGroups.post.mutationOptions(), + ) + const updateGroup = useMutation( + consoleQuery.workspaces.current.networkAccessGroups.byGroupId.put.mutationOptions(), + ) + const [dialogState, setDialogState] = useState(null) + const dialogSessionRef = useRef(0) + const [isRecovering, setIsRecovering] = useState(false) + const [recoveryError, setRecoveryError] = useState(false) + const groups = data?.groups ?? [] + const isEmpty = !isPending && !isError && groups.length === 0 + const entitled = data?.entitled === true + const canMutate = canManagePolicies && entitled + const selectedGroup = dialogState && dialogState.mode !== 'create' ? dialogState.group : null + const isSaving = createGroup.isPending || updateGroup.isPending + const viewedRef = useRef(false) + useEffect(() => { + if (!canReadPolicies || viewedRef.current) return + viewedRef.current = true + trackNetworkAccessEvent('ip_policy_interaction', { action: 'settings_tab_viewed' }) + }, [canReadPolicies]) + + const openDialog = ( + next: DialogRequest, + source: Extract = 'list_row', + ) => { + dialogSessionRef.current += 1 + setIsRecovering(false) + setRecoveryError(false) + setDialogState( + next + ? { + ...next, + analytics: startIpPolicyForm({ + mode: next.mode, + source, + ...(next.mode === 'create' ? {} : { policy_id: next.group.id }), + }), + } + : null, + ) + } + + const refreshGroupsAfterConflict = async (groupId: string, session: number) => { + if (dialogSessionRef.current !== session) return + setIsRecovering(true) + setRecoveryError(false) + try { + const refreshed = await queryClient.query( + consoleQuery.workspaces.current.networkAccessGroups.get.queryOptions({ + staleTime: 0, + retry: false, + }), + ) + if (dialogSessionRef.current !== session) return + const nextGroup = refreshed.groups.find((group) => group.id === groupId) + setDialogState((current) => + current?.mode === 'edit' && current.group.id === groupId + ? nextGroup + ? { ...current, group: nextGroup } + : null + : current, + ) + } catch { + if (dialogSessionRef.current === session) setRecoveryError(true) + } finally { + if (dialogSessionRef.current === session) setIsRecovering(false) + } + } + + const handleOpenCreate = ( + source: Extract, + ) => { + if (!canManagePolicies || isPending || isError) return + if (!entitled) { + void setPricing('open') + return + } + openDialog({ mode: 'create' }, source) + } + + if (!canReadPolicies) return null + + return ( +
+
+
+

+ {t(($) => $['settings.ipPolicies'], { ns: 'common' })} +

+

+ {t(($) => $['settings.ipPoliciesDescription'], { ns: 'common' })} +

+ {plan === 'sandbox' && ( +

+ {t(($) => $['settings.ipPoliciesProPlanRequired'], { ns: 'common' })} +

+ )} +
+ {canManagePolicies && !isEmpty && ( + + )} +
+ + {isPending && } + {!isPending && isError && ( +

+ {t(($) => $['common.loadFailed'], { ns: 'deployments' })} +

+ )} + + {isEmpty && ( +
+ +
+

+ {t(($) => $['settings.ipPolicyEmptyTitle'], { ns: 'common' })} +

+

+ {canManagePolicies + ? t(($) => $['settings.ipPolicyEmptyDescription'], { ns: 'common' }) + : t(($) => $['studio.accessControl.emptyPoliciesReadOnly'], { ns: 'deployments' })} +

+
+ {canManagePolicies && ( + + )} +
+ )} + + {!isPending && groups.length > 0 && ( +
+
+
+ {t(($) => $['settings.ipPolicyColumnName'], { ns: 'common' })} +
+
+ {t(($) => $['settings.ipPolicyColumnIpEntries'], { ns: 'common' })} +
+
+ {t(($) => $['settings.ipPolicyColumnUsedBy'], { ns: 'common' })} +
+
+ {t(($) => $['settings.ipPolicyColumnUpdatedAt'], { ns: 'common' })} +
+
+
+ {groups.map((group) => ( + openDialog({ mode: canMutate ? 'edit' : 'view', group })} + onEdit={(group) => { + if (!canMutate) return + openDialog({ mode: 'edit', group }, 'list_row_menu') + }} + /> + ))} +
+ )} + + {dialogState && (dialogState.mode !== 'create' || canMutate) && ( + { + if (selectedGroup) + void refreshGroupsAfterConflict(selectedGroup.id, dialogSessionRef.current) + }} + onOpenChange={(open) => { + if (!open) openDialog(null) + }} + onSubmit={(payload) => { + if ( + !canMutate || + isSaving || + isRecovering || + recoveryError || + dialogState.mode === 'view' + ) + return + const session = dialogSessionRef.current + const analytics = { + ...dialogState.analytics.context, + mode: dialogState.mode, + entry_count: payload.allowed_cidrs.length, + validation_error_types: [...dialogState.analytics.validationErrors], + } + const trackFailure = async (error: unknown) => { + trackNetworkAccessEvent('ip_policy_save', { + ...analytics, + result: 'failed', + fail_reason: await getIpPolicyFailReason(error), + }) + } + if (selectedGroup) { + void updateGroup + .mutateAsync( + { + params: { group_id: selectedGroup.id }, + body: { + name: payload.name, + description: selectedGroup.description ?? '', + allowed_cidrs: payload.allowed_cidrs, + expected_version: selectedGroup.version, + }, + }, + { + onSuccess: () => { + if (dialogSessionRef.current === session) openDialog(null) + }, + onError: (error) => { + if (getNetworkAccessErrorStatus(error) !== 409) return + void refreshGroupsAfterConflict(selectedGroup.id, session) + }, + }, + ) + .then( + (data) => + trackNetworkAccessEvent('ip_policy_save', { + ...analytics, + result: 'success', + policy_id: data.group.id, + }), + trackFailure, + ) + return + } + + void createGroup + .mutateAsync( + { + body: { + name: payload.name, + description: '', + allowed_cidrs: payload.allowed_cidrs, + }, + }, + { + onSuccess: () => { + if (dialogSessionRef.current === session) openDialog(null) + }, + }, + ) + .then( + (data) => + trackNetworkAccessEvent('ip_policy_save', { + ...analytics, + result: 'success', + policy_id: data.group.id, + }), + trackFailure, + ) + }} + /> + )} +
+ ) +} diff --git a/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx new file mode 100644 index 00000000000000..412b0fc7ab545c --- /dev/null +++ b/web/app/components/header/account-setting/ip-policies-page/policy-item.tsx @@ -0,0 +1,241 @@ +'use client' + +import type { NetworkAccessGroupResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import { + AlertDialog, + AlertDialogBody, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { trackNetworkAccessEvent } from '@/features/network-access/analytics' +import { PolicyReferencedApps } from '@/features/network-access/policy-references' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { consoleQuery } from '@/service/console' + +export const policyRowClassName = 'flex items-center pl-3 pr-1' +export const policyNameColClassName = 'min-w-0 flex-1 truncate' +export const policyIpEntriesColClassName = 'w-30 shrink-0' +export const policyUsedByColClassName = 'w-32 shrink-0' +export const policyUpdatedColClassName = 'w-52 shrink-0' +export const policyActionsColClassName = 'flex w-8 shrink-0 items-center justify-center' + +type PolicyItemProps = { + group: NetworkAccessGroupResponse + canMutate: boolean + onView: (group: NetworkAccessGroupResponse) => void + onEdit: (group: NetworkAccessGroupResponse) => void +} + +export function PolicyItem({ group, canMutate, onView, onEdit }: PolicyItemProps) { + const { t } = useTranslation(['common']) + const { formatTimeFromNow } = useFormatTimeFromNow() + const [menuOpen, setMenuOpen] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const deleteGroup = useMutation( + consoleQuery.workspaces.current.networkAccessGroups.byGroupId.delete.mutationOptions(), + ) + const isBound = group.used_by_count > 0 + const usedByLabel = + group.used_by_count <= 0 + ? t(($) => $['settings.ipPolicyEnforcingNone'], { ns: 'common' }) + : group.used_by_count === 1 + ? t(($) => $['settings.ipPolicyEnforcingOne'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyEnforcingMany'], { + ns: 'common', + count: group.used_by_count, + }) + + const handleDelete = () => { + if (!canMutate || deleteGroup.isPending) return + trackNetworkAccessEvent('ip_policy_interaction', { + action: 'delete_confirmed', + policy_id: group.id, + referenced_app_count: group.used_by_count, + }) + + deleteGroup.mutate( + { + params: { group_id: group.id }, + query: { expected_version: group.version }, + }, + { + onSuccess: () => { + setConfirmDelete(false) + }, + }, + ) + } + + return ( +
+ {/* Extend the native button's hit area across the row without nesting the actions. */} + +
+ {canMutate && ( + + + $['operation.moreActionsFor'], { + ns: 'common', + name: group.name, + })} + className="pointer-events-auto data-popup-open:bg-state-base-hover" + > + + + } + /> + } + /> + + {t(($) => $['settings.ipPolicyMore'], { ns: 'common' })} + + + + { + if (!canMutate) return + onEdit(group) + }} + > + {t(($) => $['operation.edit'], { ns: 'common' })} + + + { + if (!canMutate) return + setConfirmDelete(true) + trackNetworkAccessEvent('ip_policy_interaction', { + action: 'delete_attempted', + policy_id: group.id, + }) + setMenuOpen(false) + }} + > + {t(($) => $['operation.delete'], { ns: 'common' })} + + + + )} +
+ { + if (!deleteGroup.isPending) setConfirmDelete(open) + }} + > + + +
+
+ + {t(($) => $['settings.ipPolicyDeleteConfirm'], { + ns: 'common', + name: group.name, + })} + +
+ $['operation.close'], { ns: 'common' })} + disabled={deleteGroup.isPending} + onClick={() => setConfirmDelete(false)} + > + + +
+ + {isBound + ? t(($) => $['settings.ipPolicyDeleteBoundDescription'], { ns: 'common' }) + : t(($) => $['settings.ipPolicyDeleteDescription'], { ns: 'common' })} + +
+ {isBound && ( + +
+

+ {t(($) => $['settings.ipPolicyUsedBy'], { + ns: 'common', + count: group.used_by_count, + })} +

+ {group.apps.length > 0 && ( + + )} +
+
+ )} + + + trackNetworkAccessEvent('ip_policy_interaction', { + action: 'delete_cancelled', + policy_id: group.id, + }) + } + > + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.delete'], { ns: 'common' })} + + +
+
+
+ ) +} diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts index 1d3257bd1d30fb..927145feedb548 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-run-state.spec.ts @@ -1,5 +1,6 @@ import type { FeedbackType } from '@/app/components/base/chat/chat/type' import { act, renderHook, waitFor } from '@testing-library/react' +import { captureAppAccessScope, handleAppAccessError } from '@/features/app-access-error/state' import { AppSourceType } from '@/service/share' import { useResultRunState } from '../use-result-run-state' @@ -27,6 +28,8 @@ vi.mock('@/service/share', async () => { describe('useResultRunState', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + captureAppAccessScope() stopChatMessageRespondingMock.mockResolvedValue(undefined) stopWorkflowMessageMock.mockResolvedValue(undefined) updateFeedbackMock.mockResolvedValue(undefined) @@ -75,6 +78,135 @@ describe('useResultRunState', () => { expect(controller.signal.aborted).toBe(true) }) + it.each([false, true])( + 'should suppress IP denial feedback when stopping a workflow is %s', + async (isWorkflow) => { + window.history.replaceState( + {}, + '', + isWorkflow ? '/workflow/stop-test' : '/completion/stop-test', + ) + const scope = captureAppAccessScope() + const error = new Response(null, { status: 403 }) + const stop = isWorkflow ? stopWorkflowMessageMock : stopChatMessageRespondingMock + stop.mockImplementationOnce(async () => { + handleAppAccessError(403, { code: 'ip_access_denied' }, scope, error) + throw error + }) + const notify = vi.fn() + const { result } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + isWorkflow, + notify, + }), + ) + act(() => result.current.setCurrentTaskId('task-denied')) + + await act(async () => { + await expect(result.current.handleStop()).resolves.toBeUndefined() + }) + + expect(stop).toHaveBeenCalledOnce() + expect(notify).not.toHaveBeenCalled() + }, + ) + + it.each([ + ['new run', 'resolve'], + ['new run', 'reject'], + ['navigation', 'resolve'], + ['navigation', 'reject'], + ['unmount', 'resolve'], + ['unmount', 'reject'], + ] as const)('should ignore a stop after %s when it ends with %s', async (transition, outcome) => { + window.history.replaceState({}, '', '/completion/stop-race') + let resolveStop!: () => void + let rejectStop!: (error: unknown) => void + const stopRequest = new Promise((resolve, reject) => { + resolveStop = resolve + rejectStop = reject + }) + stopChatMessageRespondingMock.mockReturnValueOnce(stopRequest) + const notify = vi.fn() + const onRunControlChange = vi.fn() + const { result, unmount } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + isWorkflow: false, + notify, + onRunControlChange, + }), + ) + const controller = new AbortController() + const newController = new AbortController() + const abort = vi.spyOn(controller, 'abort') + const abortNewRun = vi.spyOn(newController, 'abort') + act(() => { + result.current.abortControllerRef.current = controller + result.current.setCurrentTaskId('task-old') + result.current.setRespondingTrue() + }) + let stopping: Promise + act(() => { + stopping = result.current.handleStop() + }) + expect(result.current.isStopping).toBe(true) + + act(() => { + if (transition === 'navigation') + window.history.replaceState({}, '', '/completion/another-app') + if (transition === 'unmount') unmount() + if (transition === 'new run') { + result.current.prepareForNewRun() + result.current.abortControllerRef.current = newController + result.current.setCurrentTaskId('task-new') + result.current.setIsStopping(true) + } + }) + const controlsBeforeCompletion = onRunControlChange.mock.calls.length + const abortsBeforeCompletion = abort.mock.calls.length + + await act(async () => { + if (outcome === 'resolve') resolveStop() + else rejectStop(new Error('Late stop failure')) + await stopping + }) + + expect(notify).not.toHaveBeenCalled() + expect(abortNewRun).not.toHaveBeenCalled() + expect(abort).toHaveBeenCalledTimes(abortsBeforeCompletion) + expect(onRunControlChange).toHaveBeenCalledTimes(controlsBeforeCompletion) + if (transition === 'new run') { + expect(result.current.currentTaskId).toBe('task-new') + expect(result.current.isStopping).toBe(true) + } + }) + + it('should not send a stop request after another public request denies access', async () => { + window.history.replaceState({}, '', '/workflow/already-denied') + const scope = captureAppAccessScope() + const notify = vi.fn() + const { result } = renderHook(() => + useResultRunState({ + appSourceType: AppSourceType.webApp, + isWorkflow: true, + notify, + }), + ) + act(() => { + result.current.setCurrentTaskId('task-denied') + handleAppAccessError(403, { code: 'ip_access_denied' }, scope) + }) + await act(async () => { + await result.current.handleStop() + }) + + expect(stopWorkflowMessageMock).not.toHaveBeenCalled() + expect(notify).not.toHaveBeenCalled() + expect(result.current.isStopping).toBe(false) + }) + it('should update feedback and react to external stop control', async () => { const notify = vi.fn() const onRunControlChange = vi.fn() diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts index fafce635961689..eb87a4e22e01d9 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts @@ -5,6 +5,7 @@ import type { PromptConfig } from '@/models/debug' import type { IOtherOptions } from '@/service/base' import type { VisionSettings } from '@/types/app' import { act, renderHook, waitFor } from '@testing-library/react' +import { captureAppAccessScope, handleAppAccessError } from '@/features/app-access-error/state' import { AppSourceType } from '@/service/share' import { withSelectorKey } from '@/test/i18n-mock' import { Resolution, TransferMethod } from '@/types/app' @@ -96,6 +97,8 @@ function renderSender( } beforeEach(() => { + window.history.replaceState({}, '', '/') + captureAppAccessScope() mocks.completion.mockResolvedValue(undefined) mocks.workflow.mockResolvedValue(undefined) mocks.sleep.mockImplementation(() => new Promise(() => {})) @@ -244,4 +247,49 @@ describe('result sending', () => { expect(result.current.state.isResponding).toBe(false) }, ) + + it.each(['denied', 'navigation'] as const)( + 'settles late completion and error callbacks silently after %s', + async (transition) => { + window.history.replaceState({}, '', '/completion/restricted-app') + const { result, notify, onCompleted } = renderSender() + await act(async () => { + await result.current.handleSend() + }) + const callbacks = mocks.completion.mock.calls[0]![1] as CompletionHandlers + act(() => { + if (transition === 'denied') + handleAppAccessError(403, { code: 'ip_access_denied' }, captureAppAccessScope()) + else { + window.history.replaceState({}, '', '/completion/another-app') + captureAppAccessScope() + } + callbacks.onCompleted() + callbacks.onError() + }) + expect(notify).not.toHaveBeenCalled() + expect(result.current.state.isResponding).toBe(false) + expect(result.current.state.messageId).toBeNull() + expect(onCompleted).toHaveBeenCalledExactlyOnceWith('', 7, false) + }, + ) + + it('silently settles a rejected workflow request after its page was denied', async () => { + window.history.replaceState({}, '', '/workflow/restricted-app') + let rejectRequest!: (error: Error) => void + mocks.workflow.mockReturnValue( + new Promise((_resolve, reject) => { + rejectRequest = reject + }), + ) + const { result, notify, onCompleted } = renderSender({ isWorkflow: true }) + await act(async () => { + await result.current.handleSend() + handleAppAccessError(403, { code: 'ip_access_denied' }, captureAppAccessScope()) + rejectRequest(new Error('Late workflow failure')) + }) + expect(notify).not.toHaveBeenCalled() + expect(result.current.state.isResponding).toBe(false) + expect(onCompleted).toHaveBeenCalledExactlyOnceWith('', 7, false) + }) }) diff --git a/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts b/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts index 8c3fd273b51f58..fe3098d920a0c7 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-run-state.ts @@ -1,10 +1,20 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' -import type { AppSourceType } from '@/service/share' import { useBoolean } from 'ahooks' import { useCallback, useEffect, useReducer, useRef, useState } from 'react' -import { stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share' +import { + captureAppAccessScope, + hasAppAccessError, + isAppAccessError, + isAppAccessScopeCurrent, +} from '@/features/app-access-error/state' +import { + AppSourceType, + stopChatMessageResponding, + stopWorkflowMessage, + updateFeedback, +} from '@/service/share' type Notify = (payload: { type: 'error'; message: string }) => void @@ -97,6 +107,7 @@ export const useResultRunState = ({ }) const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0) const abortControllerRef = useRef(null) + const runGenerationRef = useRef(0) const [{ currentTaskId, isStopping }, dispatchRunControl] = useReducer(runControlReducer, { currentTaskId: null, isStopping: false, @@ -131,6 +142,7 @@ export const useResultRunState = ({ const getWorkflowProcessData = useCallback(() => workflowProcessDataRef.current, []) const resetRunState = useCallback(() => { + runGenerationRef.current += 1 dispatchRunControl({ type: 'reset' }) abortControllerRef.current = null onRunControlChange?.(null) @@ -165,17 +177,28 @@ export const useResultRunState = ({ const handleStop = useCallback(async () => { if (!currentTaskId || isStopping) return + const runGeneration = runGenerationRef.current + const appAccessScope = appSourceType === AppSourceType.webApp ? captureAppAccessScope() : null + const isCurrentRequest = () => + runGeneration === runGenerationRef.current && + isAppAccessScopeCurrent(appAccessScope) && + !hasAppAccessError(appAccessScope) + if (!isCurrentRequest()) return + + const abortController = abortControllerRef.current setIsStopping(true) try { if (isWorkflow) await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') else await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') - abortControllerRef.current?.abort() + if (isCurrentRequest()) abortController?.abort() } catch (error) { + if (!isCurrentRequest() || isAppAccessError(error)) return + const message = error instanceof Error ? error.message : String(error) notify({ type: 'error', message }) } finally { - setIsStopping(false) + if (isCurrentRequest()) setIsStopping(false) } }, [appId, appSourceType, currentTaskId, isStopping, isWorkflow, notify, setIsStopping]) @@ -183,6 +206,13 @@ export const useResultRunState = ({ setControlClearMoreLikeThis(Date.now()) }, []) + useEffect( + () => () => { + runGenerationRef.current += 1 + }, + [], + ) + useEffect(() => { const abortCurrentRequest = () => { abortControllerRef.current?.abort() diff --git a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts index f9d94e7afd6ddd..ce16701f165a51 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts @@ -7,6 +7,11 @@ import { useCallback, useEffect, useRef } from 'react' import { trackWebAppEvent } from '@/app/components/base/amplitude/web-app-event' import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' import { useWebAppStore } from '@/context/web-app-context' +import { + captureAppAccessScope, + hasAppAccessError, + isAppAccessScopeCurrent, +} from '@/features/app-access-error/state' import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/service/share' import { sleep } from '@/utils' import { buildResultRequestData, validateResultRequest } from '../result-request' @@ -89,6 +94,7 @@ export const useResultSender = ({ promptConfig, visionConfig, }) + const appAccessScope = appSourceType === AppSourceType.webApp ? captureAppAccessScope() : null runState.prepareForNewRun() @@ -106,6 +112,18 @@ export const useResultSender = ({ let isTimeout = false let completionChunks: string[] = [] let tempMessageId = '' + const stopInactiveRequest = () => { + if (isAppAccessScopeCurrent(appAccessScope) && !hasAppAccessError(appAccessScope)) + return false + + if (!isEnd) { + runState.setRespondingFalse() + runState.resetRunState() + if (!isTimeout) onCompleted(runState.getCompletionRes(), taskId, false) + isEnd = true + } + return true + } void (async () => { await sleep(TEXT_GENERATION_TIMEOUT_MS) @@ -140,6 +158,7 @@ export const useResultSender = ({ }) void sendWorkflowMessage(data, otherOptions, appSourceType, appId).catch((error) => { + if (stopInactiveRequest()) return runState.setRespondingFalse() runState.resetRunState() logRequestError(notify, error) @@ -159,6 +178,7 @@ export const useResultSender = ({ runState.setCompletionRes(completionChunks.join('')) }, onCompleted: () => { + if (stopInactiveRequest()) return if (isTimeout) { notify({ type: 'warning', @@ -178,6 +198,7 @@ export const useResultSender = ({ runState.setCompletionRes(completionChunks.join('')) }, onError: () => { + if (stopInactiveRequest()) return if (isTimeout) { notify({ type: 'warning', diff --git a/web/app/layout.tsx b/web/app/layout.tsx index c3f69c21e8de47..8af69ab5789966 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -8,6 +8,8 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app' import { AppToastHost } from '@/app/notifications/host' import { IS_PROD } from '@/config' import { getDatasetMap } from '@/env' +import AppAccessBoundary from '@/features/app-access-error/boundary' +import { getAppAccessErrorDocument } from '@/features/app-access-error/document' import { SystemFeaturesBootstrapBoundary } from '@/features/system-features/bootstrap-boundary' import { dehydrateSystemFeatures, @@ -33,6 +35,8 @@ export const viewport: Viewport = { } export async function generateMetadata(): Promise { + if (getAppAccessErrorDocument(await headers())) + return { icons: { icon: `${basePath}/favicon.ico` } } const systemFeatures = await getOptionalSystemFeatures() const branding = systemFeatures?.branding const applicationTitle = getApplicationTitle(branding) @@ -51,12 +55,7 @@ export async function generateMetadata(): Promise { export default async function RootLayout({ children }: { children: React.ReactNode }) { const datasetMap = getDatasetMap() - const [locale, requestHeaders] = await Promise.all([ - getLocaleOnServer(), - headers(), - getOptionalSystemFeatures(), - ]) - const dehydratedState = dehydrateSystemFeatures() + const requestHeaders = await headers() const nonce = IS_PROD ? (requestHeaders.get('x-nonce') ?? undefined) : undefined const themeProviderProps: Omit = { attribute: 'data-theme', @@ -66,33 +65,41 @@ export default async function RootLayout({ children }: { children: React.ReactNo } if (nonce !== undefined) themeProviderProps.nonce = nonce + const errorDocument = getAppAccessErrorDocument(requestHeaders) + const [locale] = errorDocument + ? [errorDocument.locale] + : await Promise.all([getLocaleOnServer(), getOptionalSystemFeatures()]) + const dehydratedState = errorDocument ? undefined : dehydrateSystemFeatures() + return ( - - - + {!errorDocument && } - + {!errorDocument && }
- - - - - - - {children} - - - - - + {errorDocument ? ( + children + ) : ( + + + + + + + {children} + + + + + + )} - + {!errorDocument && }
diff --git a/web/app/styles/globals.css b/web/app/styles/globals.css index 9cb7d3a459f995..44f8e606661896 100644 --- a/web/app/styles/globals.css +++ b/web/app/styles/globals.css @@ -3,6 +3,7 @@ @source '../../app'; @source '../../context'; @source '../../hooks'; +@source '../../features'; @source '../../../packages/dify-ui/src'; @source '../../node_modules/streamdown/dist'; @source '../../node_modules/@streamdown/math/dist'; diff --git a/web/app/webapp-unavailable/page.tsx b/web/app/webapp-unavailable/page.tsx new file mode 100644 index 00000000000000..b4c50c0cb7d058 --- /dev/null +++ b/web/app/webapp-unavailable/page.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from '@/next' +import { createInstance } from 'i18next' +import Negotiator from 'negotiator' +import { APP_UNAVAILABLE_IP_HEADER } from '@/features/app-access-error/document-constants' +import { getBrowserLocale } from '@/features/app-access-error/locale' +import AppNotAccessible from '@/features/app-access-error/page' +import { loadI18nResource } from '@/i18n/load-resource' +import { headers } from '@/next/headers' + +export const dynamic = 'force-dynamic' + +async function documentLocale() { + const requestHeaders = await headers() + return getBrowserLocale( + new Negotiator({ + headers: { 'accept-language': requestHeaders.get('accept-language') ?? '' }, + }).languages(), + ) +} + +export async function generateMetadata(): Promise { + const locale = await documentLocale() + const resource = await loadI18nResource(locale, 'share') + const i18n = createInstance() + await i18n.init({ + lng: locale, + fallbackLng: false, + keySeparator: false, + resources: { [locale]: { share: resource.default } }, + }) + return { + title: { + absolute: i18n.getFixedT(locale, 'share')(($) => $['appNotAccessible.documentTitle']), + }, + robots: { index: false }, + } +} + +export default async function UnavailableWebAppDocument() { + const locale = await documentLocale() + const [requestHeaders, share, common, login, fallbackShare, fallbackCommon, fallbackLogin] = + await Promise.all([ + headers(), + loadI18nResource(locale, 'share'), + loadI18nResource(locale, 'common'), + loadI18nResource(locale, 'login'), + loadI18nResource('en-US', 'share'), + loadI18nResource('en-US', 'common'), + loadI18nResource('en-US', 'login'), + ]) + return ( + + ) +} diff --git a/web/docs/webapp-document-gate.md b/web/docs/webapp-document-gate.md new file mode 100644 index 00000000000000..5f4cd1e8daba60 --- /dev/null +++ b/web/docs/webapp-document-gate.md @@ -0,0 +1,18 @@ +# WebApp document availability gate + +The optional Proxy gate checks default `/chat/:code`, `/chatbot/:code`, `/workflow/:code`, `/completion/:code`, and `/agent/:code` addresses before Next starts rendering. It does not replace Gateway checks on subsequent API requests. Environment/Enterprise address routing is unchanged. + +Configure both server-only variables: + +- `WEBAPP_ACCESS_PREFLIGHT_URL`: the fixed internal Gateway `/api/webapp/access-mode` endpoint. Do not point it at Core or a public redirecting origin. +- `WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET`: a random shared ingress secret. Do not use a `NEXT_PUBLIC_` variable, commit its value, include it in logs, or expose it in a response. + +Every ingress location that forwards documents to Web must **overwrite**, not append or preserve, `X-Dify-Webapp-Client-Ip` with its validated visitor address and `X-Dify-Webapp-Proxy-Secret` with this secret. The ingress must itself have an explicit trusted-proxy policy; an arbitrary inbound XFF/CF header is not evidence of visitor identity. Web should not have an unprotected public port. + +Web verifies the proof with a constant-time comparison and validates a single IP, then creates a fresh Gateway request with only `Accept` and a one-address `X-Forwarded-For`. Gateway must trust the Web peer's network and resolve the forwarded address using its existing trusted-hop policy. Original XFF, Forwarded, CF headers, Authorization, Cookie, and ingress proof are not forwarded. The URL is configuration-owned; request queries cannot select another upstream or supply an IP. + +An unset URL leaves self-hosted routing unchanged. Once configured, missing/invalid attestation, timeout, redirects, invalid payloads, and dependency failures produce a non-cacheable 503. They are not application-not-found responses. Only the canonical Gateway/Core `404 app_not_found` produces the existing generic unavailable page, with a 404 status fixed before rendering and `Cache-Control: no-store`. The allow path retains existing rendering and authentication behavior. + +The internal `/webapp-unavailable` destination has no application lookup, uses no caller-supplied query state, and is always a 404. Proxy clears its internal IP header on every request, including direct visits. Only a verified denial can populate that header. Locale resources are provided for server rendering; the existing error-page UI remains the owner of language selection, appearance, and navigation. + +Validation must include real Next HTTP requests, not only an assertion on `NextResponse.status`: verify document status, absence of redirect, non-cacheability, matching missing/denied body, and unchanged allowed rendering. API availability and the edge trust configuration must be deployed before enabling these variables. Do not enable them globally in an environment whose Gateway does not route `/api`. diff --git a/web/env.ts b/web/env.ts index 3c4cfe24e3e301..763a531e8e41d7 100644 --- a/web/env.ts +++ b/web/env.ts @@ -194,6 +194,10 @@ export const env = createEnv({ * The timeout for the text generation in millisecond */ TEXT_GENERATION_TIMEOUT_MS: coercedNumber.default(60000), + /** Ingress proof for the optional pre-render WebApp gate; never expose to the browser. */ + WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET: z.string().optional(), + /** Fixed internal Gateway access-mode URL. Unset preserves self-hosted routing. */ + WEBAPP_ACCESS_PREFLIGHT_URL: z.url().optional(), /** * The timeout for the cmd+k workflow generation in millisecond */ diff --git a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx index 83e0a1fe13f3cc..40ee63a328fc6f 100644 --- a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx +++ b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx @@ -105,12 +105,13 @@ describe('AgentDetailLayout', () => { expect(router.replace).not.toHaveBeenCalled() }) - it('redirects to the roster when the agent no longer exists', async () => { + it('redirects a missing agent to the roster', async () => { const { wrapper, queryClient } = setup() queryClient.removeQueries({ queryKey: detailKey }) detailRequest.mockRejectedValueOnce(createHttpError(new Response(null, { status: 404 }))) render(content, { wrapper }) await waitFor(() => expect(router.replace).toHaveBeenCalledWith('/agents')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByText('Agent detail content')).not.toBeInTheDocument() }) }) diff --git a/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx new file mode 100644 index 00000000000000..5d9147bda19f98 --- /dev/null +++ b/web/features/agent-v2/agent-detail/access/__tests__/page.spec.tsx @@ -0,0 +1,217 @@ +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { AgentPermission } from '@/features/agent-v2/acl' +import { consoleQuery } from '@/service/console' +import { + createNetworkAccessGroupFixture, + seedAppNetworkAccessGroup, + seedNetworkAccessGroups, +} from '@/test/console/network-access' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createAgentFixture } from '@/test/fixtures/agent' +import { AgentAccessPage } from '../page' + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.test${path}`, +})) + +// These cards own independent publishing and API-key workflows. +vi.mock('../components/web-app-access-card', () => ({ WebAppAccessCard: () => null })) +vi.mock('../components/service-api-access-card', () => ({ ServiceApiAccessCard: () => null })) +vi.mock('../components/workflow-references-table', () => ({ WorkflowReferencesTable: () => null })) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + const { default: translations } = await import('@/i18n/locales/en-US/deployments.json') + return createReactI18nextMock({ + ...translations, + 'studio.accessControl.entryLabel': 'Access Control', + 'studio.accessControl.chipOff': 'Off', + 'studio.accessControl.chipOn': 'ON', + 'studio.accessControl.chipPartial': '{{n}} of {{m}}', + 'studio.accessControl.restrictByIp': 'Restrict by IP address', + 'studio.accessControl.protectingAll': 'Protecting all {{count}} access points.', + 'studio.accessControl.selectPolicy': 'Select a policy', + 'studio.accessControl.ipPolicy': 'IP Policy', + 'studio.accessControl.manageIpPolicies': 'Manage IP policies', + 'overview.appInfo.title': 'Web App', + 'overview.apiInfo.title': 'Backend Service API', + 'mcp.server.title': 'MCP Server', + 'settings.trigger': 'Trigger', + 'operation.save': 'Save', + 'operation.edit': 'Edit', + 'operation.cancel': 'Cancel', + }) +}) + +function setup(overrides: Partial = {}, configured = false) { + const queryClient = createConsoleQueryClient() + const agent = createAgentFixture({ + app_id: 'public-app', + backing_app_id: 'runtime-app', + hidden_app_backed: false, + ...overrides, + }) + queryClient.setQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agent.id } } }), + agent, + ) + const policy = createNetworkAccessGroupFixture() + seedNetworkAccessGroups(queryClient, { groups: [policy] }) + const binding = configured + ? { + id: 'binding-1', + tenant_id: 'workspace-1', + app_id: 'public-app', + enabled: true, + group_id: policy.id, + access_points: ['webapp', 'service_api'] as const, + version: 1, + updated_by_account_id: null, + created_at: policy.created_at, + updated_at: policy.updated_at, + } + : null + const appBinding = seedAppNetworkAccessGroup(queryClient, 'public-app', { + available_access_points: ['webapp', 'service_api'], + binding: binding ? { ...binding, access_points: [...binding.access_points] } : null, + effective_enabled: configured, + }) + const renderPage = (agentId: string) => ( + + + + ) + const rendered = renderWithConsoleQuery(renderPage(agent.id), { + queryClient, + currentWorkspace: { id: 'workspace-1', role: 'editor' }, + systemFeatures: { deployment_edition: 'CLOUD' }, + }) + return { ...rendered, agent, policy, appBinding, renderPage } +} + +describe('Agent IP access configuration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lets an Editor configure the public App ID with exactly two access points', async () => { + const user = userEvent.setup() + const { appBinding, policy } = setup({ + access_ready: false, + enable_site: false, + enable_api: false, + }) + const writes: Array<{ url: string; body: unknown }> = [] + vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(String(input), init) + if (request.url.endsWith('/check-current-ip')) + return Response.json({ allowed: true, client_ip: '203.0.113.42', policy_version: 1 }) + if (request.method === 'PUT') { + const body = await request.json() + writes.push({ url: request.url, body }) + } + if (request.url.includes('/workspaces/current/network-access-groups')) + return Response.json({ tenant_id: 'workspace-1', entitled: true, groups: [policy] }) + return Response.json(appBinding) + }) + + await user.click(await screen.findByRole('button', { name: /Access Control/ })) + expect(screen.getByRole('switch', { name: 'Web App' })).toBeChecked() + expect(screen.getByRole('switch', { name: 'Backend Service API' })).toBeChecked() + expect(screen.queryByRole('switch', { name: 'MCP Server' })).not.toBeInTheDocument() + expect(screen.queryByRole('switch', { name: 'Trigger' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(writes).toEqual([ + { + url: 'http://localhost:5001/console/api/apps/public-app/network-access-group', + body: { + enabled: true, + group_id: 'group-1', + access_points: ['webapp', 'service_api'], + expected_version: 0, + }, + }, + ]), + ) + }) + + it.each([{ app_id: null }, { hidden_app_backed: true }, { permission_keys: [] }])( + 'does not offer an independent IP binding for an ineligible Agent: %o', + (overrides) => { + setup(overrides) + expect(screen.queryByRole('button', { name: /Access Control/ })).not.toBeInTheDocument() + expect(globalThis.fetch).not.toHaveBeenCalled() + }, + ) + + it('shows both protected endpoints as complete coverage for a read-only Agent', async () => { + const user = userEvent.setup() + setup({ permission_keys: [AgentPermission.AccessPointView] }, true) + const chip = await screen.findByRole('button', { name: /Access Control/ }) + expect(chip).toHaveTextContent('ON') + await user.click(chip) + expect(screen.getByText('Protecting all 2 access points.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + const toggle = screen.getByRole('switch', { name: 'Restrict by IP address' }) + expect(toggle).toHaveAttribute('aria-disabled', 'true') + await user.click(toggle) + expect(toggle).toBeChecked() + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.filter(([input]) => input instanceof Request && input.method !== 'GET'), + ).toHaveLength(0) + }) + + it('closes an unsaved policy draft when switching Agents', async () => { + const user = userEvent.setup() + const { queryClient, rerender, renderPage } = setup() + await user.click(await screen.findByRole('button', { name: /Access Control/ })) + await user.click(screen.getByRole('button', { name: /Internal Network/ })) + await user.click(screen.getByRole('switch', { name: 'Backend Service API' })) + + const second = createAgentFixture({ id: 'agent-2', app_id: 'second-app' }) + queryClient.setQueryData( + consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: second.id } } }), + second, + ) + seedAppNetworkAccessGroup(queryClient, 'second-app', { + available_access_points: ['webapp', 'service_api'], + }) + rerender(renderPage(second.id)) + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(), + ) + await user.click(screen.getByRole('button', { name: /Access Control/ })) + expect(screen.getByRole('switch', { name: 'Backend Service API' })).toBeChecked() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('requires lockout confirmation for an Agent with a published snapshot', async () => { + const user = userEvent.setup() + setup({ access_ready: true }) + vi.mocked(globalThis.fetch).mockImplementation(async () => + Response.json({ allowed: false, client_ip: '203.0.113.42', policy_version: 1 }), + ) + await user.click(screen.getByRole('button', { name: /Access Control/ })) + await user.click(screen.getByRole('button', { name: /Internal Network/ })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect( + await screen.findByRole('alertdialog', { name: 'Save without your own IP?' }), + ).toBeInTheDocument() + expect( + vi + .mocked(globalThis.fetch) + .mock.calls.every(([input, init]) => new Request(input, init).method === 'GET'), + ).toBe(true) + }) +}) diff --git a/web/features/agent-v2/agent-detail/access/page.tsx b/web/features/agent-v2/agent-detail/access/page.tsx index ef751b8e4c3b9b..d9c94206c6cd21 100644 --- a/web/features/agent-v2/agent-detail/access/page.tsx +++ b/web/features/agent-v2/agent-detail/access/page.tsx @@ -10,6 +10,8 @@ import { import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' +import { getAgentACLCapabilities } from '@/features/agent-v2/acl' +import { AccessControlEntry } from '@/features/network-access/app-binding' import { consoleQuery } from '@/service/console' import { AgentDetailSectionSurface } from '../section-surface' import { ServiceApiAccessCard } from './components/service-api-access-card' @@ -32,10 +34,12 @@ export function AgentAccessPage({ agentId }: AgentAccessPageProps) { }, }), ) + const agent = agentQuery.data + const capabilities = getAgentACLCapabilities(agent?.permission_keys) return ( $['agentDetail.sections.access'])}> -
+

{t(($) => $['agentDetail.access.title'])} @@ -53,6 +57,15 @@ export function AgentAccessPage({ agentId }: AgentAccessPageProps) {

+ {agent?.app_id && !agent.hidden_app_backed && capabilities.canViewAccessPoint && ( + + )}
diff --git a/web/features/app-access-error/__tests__/boundary.spec.tsx b/web/features/app-access-error/__tests__/boundary.spec.tsx new file mode 100644 index 00000000000000..70ab9230ac13fe --- /dev/null +++ b/web/features/app-access-error/__tests__/boundary.spec.tsx @@ -0,0 +1,215 @@ +import { QueryClient, QueryClientProvider, queryOptions, useQuery } from '@tanstack/react-query' +import { act, render, screen } from '@testing-library/react' +import { createInstance } from 'i18next' +import { StrictMode } from 'react' +import { I18nextProvider, initReactI18next } from 'react-i18next' +import enCommon from '@/i18n/locales/en-US/common.json' +import enShare from '@/i18n/locales/en-US/share.json' +import AppAccessBoundary from '../boundary' +import { captureAppAccessScope, handleAppAccessError } from '../state' + +vi.unmock('react-i18next') +vi.mock('@/i18n/load-resource', () => ({ + loadI18nResource: async (_locale: string, namespace: string) => ({ + default: namespace === 'share' ? enShare : enCommon, + }), +})) +vi.mock('@/next/navigation', () => ({ + usePathname: () => window.location.pathname, + useSearchParams: () => new URLSearchParams(window.location.search), +})) + +const i18n = createInstance() + +function AppBootstrap({ request }: { request: () => Promise }) { + useQuery(queryOptions({ queryKey: ['app-bootstrap'], queryFn: request })) + return

Application loading

+} + +describe('IP denial page boundary', () => { + beforeEach(async () => { + window.history.replaceState({}, '', '/') + captureAppAccessScope() + await i18n.use(initReactI18next).init({ + lng: 'en-US', + fallbackLng: 'en-US', + defaultNS: 'share', + keySeparator: false, + interpolation: { escapeValue: false }, + resources: { 'en-US': { share: enShare } }, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + '/chat/app', + '/environment/workflow/app', + '/form/token', + '/app/app/workflow', + '/agents/app/access', + '/explore/installed/app', + ])('replaces %s and stops query retries after the first IP rejection', async (path) => { + window.history.replaceState({}, '', path) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 3, retryDelay: 100 } }, + }) + const request = vi.fn(async () => { + const scope = captureAppAccessScope() + const error = new Response('', { status: 403 }) + handleAppAccessError( + 403, + { code: 'ip_access_denied', client_ip: '203.0.113.42' }, + scope, + error, + ) + throw error + }) + render( + + + + + + + , + ) + + expect( + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + expect(request).toHaveBeenCalledTimes(1) + expect(screen.queryByText('Application loading')).not.toBeInTheDocument() + expect(screen.getByText('203.0.113.42', { selector: 'code' })).toBeInTheDocument() + + vi.useFakeTimers() + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(request).toHaveBeenCalledTimes(1) + queryClient.clear() + }) + + it('renders the new app immediately and ignores the old app response after navigation', async () => { + window.history.replaceState({}, '', '/chat/first') + const previousScope = captureAppAccessScope() + const contents = ( + + +

Current application

+
+
+ ) + const { rerender } = render(contents) + act(() => { + handleAppAccessError(403, { code: 'ip_access_denied' }, previousScope) + }) + expect( + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + + window.history.replaceState({}, '', '/agent/second') + rerender( + + +

Current application

+
+
, + ) + act(() => { + handleAppAccessError(403, { code: 'ip_access_denied' }, previousScope) + }) + expect(screen.getByText('Current application')).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).not.toBeInTheDocument() + }) + + it('starts a new visit after leaving for a console page with no public requests', async () => { + window.history.replaceState({}, '', '/chat/app') + const previousScope = captureAppAccessScope() + const application = ( + + +

Current application

+
+
+ ) + const { rerender } = render(application) + act(() => { + handleAppAccessError(403, { code: 'ip_access_denied' }, previousScope) + }) + expect( + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + + window.history.replaceState({}, '', '/apps') + rerender( + + +

Console

+
+
, + ) + expect(screen.getByText('Console')).toBeInTheDocument() + + window.history.replaceState({}, '', '/chat/app') + rerender(application) + const currentScope = captureAppAccessScope() + expect(currentScope?.key).toBe(previousScope?.key) + expect(currentScope?.epoch).not.toBe(previousScope?.epoch) + act(() => { + handleAppAccessError(403, { code: 'ip_access_denied' }, previousScope) + }) + expect(screen.getByText('Current application')).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).not.toBeInTheDocument() + + act(() => { + handleAppAccessError(403, { code: 'ip_access_denied' }, currentScope) + }) + expect( + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + }) + + it('preserves a bootstrap rejection when Strict Mode replays effects', async () => { + window.history.replaceState({}, '', '/chat/app') + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const request = vi.fn(async () => { + const scope = captureAppAccessScope() + const error = new Response('', { status: 403 }) + handleAppAccessError( + 403, + { code: 'ip_access_denied', client_ip: '203.0.113.42' }, + scope, + error, + ) + throw error + }) + render( + + + + + + + + + , + ) + + expect( + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + expect(screen.queryByText('Application loading')).not.toBeInTheDocument() + expect(screen.getByText('203.0.113.42', { selector: 'code' })).toBeInTheDocument() + expect(request).toHaveBeenCalledTimes(1) + queryClient.clear() + }) +}) diff --git a/web/features/app-access-error/__tests__/document-response.spec.ts b/web/features/app-access-error/__tests__/document-response.spec.ts new file mode 100644 index 00000000000000..749e67ae940790 --- /dev/null +++ b/web/features/app-access-error/__tests__/document-response.spec.ts @@ -0,0 +1,227 @@ +// oxlint-disable-next-line no-restricted-imports -- Exercise the real HTTP request/response boundary used by Proxy, not a mocked status wrapper. +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { proxy } from '@/proxy' +import { APP_UNAVAILABLE_IP_HEADER } from '../document-constants' +import { getWebAppDocumentResponse } from '../document-response' + +const configuration = vi.hoisted(() => ({ + WEBAPP_ACCESS_PREFLIGHT_URL: 'http://gateway.internal:8080/api/webapp/access-mode' as + | string + | undefined, + WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET: 'unit-test-ingress-proof' as string | undefined, +})) +vi.mock('@/env', () => ({ env: configuration })) + +const fetchMock = vi.fn() +function request(path = '/chat/code', headers: Record = {}) { + return new NextRequest(`https://apps.example${path}`, { + headers: { + 'X-Dify-Webapp-Client-Ip': '203.0.113.42', + 'X-Dify-Webapp-Proxy-Secret': 'unit-test-ingress-proof', + ...headers, + }, + }) +} +const gate = (incoming: NextRequest) => + getWebAppDocumentResponse(incoming, new Headers(incoming.headers)) + +beforeEach(() => { + configuration.WEBAPP_ACCESS_PREFLIGHT_URL = 'http://gateway.internal:8080/api/webapp/access-mode' + configuration.WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET = 'unit-test-ingress-proof' + fetchMock.mockReset() + fetchMock.mockResolvedValue(Response.json({ accessMode: 'public' })) + vi.stubGlobal('fetch', fetchMock) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('WebApp document admission', () => { + it.each(['chat', 'chatbot', 'workflow', 'completion', 'agent'])( + 'checks %s before rendering with a fresh trusted-hop request', + async (kind) => { + expect( + await gate( + request(`/${kind}/co%64e?client_ip=198.51.100.1&appCode=other`, { + 'X-Forwarded-For': '198.51.100.1', + 'X-Real-IP': '198.51.100.2', + Forwarded: 'for=198.51.100.3', + 'CF-Connecting-IP': '198.51.100.4', + Authorization: 'Bearer untrusted', + Cookie: 'session=not-forwarded', + }), + ), + ).toBeUndefined() + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + new URL('http://gateway.internal:8080/api/webapp/access-mode?appCode=code'), + { + method: 'GET', + cache: 'no-store', + redirect: 'error', + headers: { Accept: 'application/json', 'X-Forwarded-For': '203.0.113.42' }, + signal: expect.any(AbortSignal), + }, + ) + }, + ) + + it.each([ + '/apps', + '/chatty/code', + '/chat/code/extra', + '/environment/chat/code', + '/environment/workflow/code', + '/webapp-signin', + '/form/code', + ])('does not expand the gate to %s', async (path) => { + expect(await gate(request(path))).toBeUndefined() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('leaves the optional gate disabled when its URL is unset', async () => { + configuration.WEBAPP_ACCESS_PREFLIGHT_URL = undefined + expect(await gate(request())).toBeUndefined() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each>([ + { 'X-Dify-Webapp-Proxy-Secret': '' }, + { 'X-Dify-Webapp-Proxy-Secret': 'wrong' }, + { 'X-Dify-Webapp-Client-Ip': '' }, + { 'X-Dify-Webapp-Client-Ip': '203.0.113.42, 198.51.100.1' }, + { 'X-Dify-Webapp-Client-Ip': 'unknown' }, + ])('fails closed without trusted ingress proof %j', async (headers) => { + const result = await gate(request('/chat/code', headers)) + expect(result?.status).toBe(503) + expect(result?.headers.get('cache-control')).toBe('no-store') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects configured preflight without the matching server secret', async () => { + configuration.WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET = undefined + expect((await gate(request()))?.status).toBe(503) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each(['public', 'private', 'private_all', 'sso_verified'])( + 'retains normal rendering/authentication for access mode %s', + async (accessMode) => { + fetchMock.mockResolvedValue(Response.json({ accessMode })) + expect(await gate(request())).toBeUndefined() + }, + ) + + it('rewrites canonical missing/denied responses to one 404 without exposing application data', async () => { + fetchMock.mockResolvedValue( + Response.json( + { code: 'app_not_found', status: 404, message: 'App not found.' }, + { status: 404 }, + ), + ) + const incoming = request('/workflow/code?client_ip=198.51.100.1', { + [APP_UNAVAILABLE_IP_HEADER]: '198.51.100.2', + }) + const forwarded = new Headers(incoming.headers) + const result = await getWebAppDocumentResponse(incoming, forwarded) + expect(result?.status).toBe(404) + expect(result?.headers.get('x-middleware-rewrite')).toBe( + 'https://apps.example/webapp-unavailable', + ) + expect(result?.headers.get('location')).toBeNull() + expect(result?.headers.get('cache-control')).toBe('no-store') + expect(forwarded.get(APP_UNAVAILABLE_IP_HEADER)).toBe('203.0.113.42') + expect(forwarded.has('x-dify-webapp-proxy-secret')).toBe(false) + expect(forwarded.has('x-dify-webapp-client-ip')).toBe(false) + }) + + it('clears fabricated internal IP data on a direct error-page visit', async () => { + const incoming = request('/webapp-unavailable?client_ip=198.51.100.1', { + [APP_UNAVAILABLE_IP_HEADER]: '198.51.100.2', + }) + const forwarded = new Headers(incoming.headers) + const result = await getWebAppDocumentResponse(incoming, forwarded) + expect(result?.status).toBe(404) + expect(forwarded.has(APP_UNAVAILABLE_IP_HEADER)).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps the RSC cache-busting value while removing application query state', async () => { + fetchMock.mockResolvedValue( + Response.json( + { code: 'app_not_found', status: 404, message: 'App not found.' }, + { status: 404 }, + ), + ) + const result = await gate(request('/chat/code?_rsc=transport-value&client_ip=198.51.100.1')) + expect(result?.status).toBe(404) + expect(result?.headers.get('x-middleware-rewrite')).toBe( + 'https://apps.example/webapp-unavailable?_rsc=transport-value', + ) + }) + + it('keeps the configured Next basePath on the internal destination', async () => { + fetchMock.mockResolvedValue( + Response.json( + { code: 'app_not_found', status: 404, message: 'App not found.' }, + { status: 404 }, + ), + ) + const incoming = new NextRequest('https://apps.example/dify/chat/code', { + nextConfig: { basePath: '/dify' }, + headers: request().headers, + }) + const result = await gate(incoming) + expect(result?.status).toBe(404) + expect(result?.headers.get('x-middleware-rewrite')).toBe( + 'https://apps.example/dify/webapp-unavailable', + ) + }) + + it('does not let a browser choose the root error-layout branch for an allowed app', async () => { + const result = await proxy( + request('/chat/code', { + 'x-dify-pathname': '/webapp-unavailable', + [APP_UNAVAILABLE_IP_HEADER]: '198.51.100.1', + }), + ) + expect(result.status).toBe(200) + expect(result.headers.get('x-middleware-rewrite')).toBeNull() + expect(result.headers.get('x-middleware-request-x-dify-pathname')).toBe('/chat/code') + expect(result.headers.has(`x-middleware-request-${APP_UNAVAILABLE_IP_HEADER}`)).toBe(false) + }) + + it.each([401, 403, 429, 500, 502, 503])( + 'does not disguise upstream %s as not-found', + async (status) => { + fetchMock.mockResolvedValue(Response.json({ code: 'upstream_error' }, { status })) + expect((await gate(request()))?.status).toBe(503) + }, + ) + + it.each([ + () => new Response('

404

', { status: 404 }), + () => Response.json({ code: 'other_resource_not_found' }, { status: 404 }), + () => + Response.json( + { code: 'app_not_found', status: 500, message: 'App not found.' }, + { status: 404 }, + ), + () => + Response.json( + { code: 'app_not_found', status: 404, message: 'An upstream error' }, + { status: 404 }, + ), + () => Response.json({ code: 'app_not_found' }, { status: 404 }), + () => Response.json({ unexpected: true }), + () => Response.json({ code: 'app_not_found', padding: 'x'.repeat(8192) }, { status: 404 }), + () => new Response('{', { headers: { 'content-type': 'application/json' }, status: 404 }), + ])('treats invalid upstream payloads as unavailable, not app-not-found', async (response) => { + fetchMock.mockResolvedValue(response()) + expect((await gate(request()))?.status).toBe(503) + }) + + it('does not use an allow fallback after a network/timeout failure', async () => { + fetchMock.mockRejectedValue(new DOMException('Timed out', 'TimeoutError')) + expect((await gate(request()))?.status).toBe(503) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/features/app-access-error/__tests__/page.spec.tsx b/web/features/app-access-error/__tests__/page.spec.tsx new file mode 100644 index 00000000000000..5d187348b17c10 --- /dev/null +++ b/web/features/app-access-error/__tests__/page.spec.tsx @@ -0,0 +1,125 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createInstance } from 'i18next' +import { renderToString } from 'react-dom/server' +import { I18nextProvider } from 'react-i18next' +import enCommon from '@/i18n/locales/en-US/common.json' +import enLogin from '@/i18n/locales/en-US/login.json' +import enShare from '@/i18n/locales/en-US/share.json' +import jaCommon from '@/i18n/locales/ja-JP/common.json' +import jaLogin from '@/i18n/locales/ja-JP/login.json' +import jaShare from '@/i18n/locales/ja-JP/share.json' +import zhLogin from '@/i18n/locales/zh-Hans/login.json' +import zhShare from '@/i18n/locales/zh-Hans/share.json' +import { getBrowserLocale } from '../locale' +import AppNotAccessible from '../page' + +vi.unmock('react-i18next') +vi.mock('@/next/navigation', () => ({ + usePathname: () => window.location.pathname, + useSearchParams: () => new URLSearchParams(window.location.search), +})) + +beforeEach(() => { + window.history.replaceState({}, '', '/chat/app') + vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']) + document.title = 'Previous title' + document.cookie = 'locale=zh-Hans' +}) +afterEach(() => vi.restoreAllMocks()) + +it('server-renders the document title text and IP from preloaded browser-language resources', () => { + const html = renderToString( + , + ) + expect(html).toMatch(new RegExp(`]*>${jaShare['appNotAccessible.title']}`)) + expect(html).toMatch(/]*>192\.0\.2\.81<\/code>/) + expect(html).not.toContain(enShare['appNotAccessible.title']) +}) + +it.each([ + ['en-GB', enShare, enLogin], + ['zh-CN', zhShare, zhLogin], + ['ja', jaShare, jaLogin], +])('uses browser language %s for exact page and document copy', async (language, copy, login) => { + vi.spyOn(navigator, 'languages', 'get').mockReturnValue([language]) + const parent = createInstance() + await parent.init({ lng: 'zh-Hans' }) + render( + + + , + ) + expect( + await screen.findByRole('heading', { name: copy['appNotAccessible.title'] }), + ).toBeInTheDocument() + expect(screen.getByText(copy['appNotAccessible.description'])).toBeInTheDocument() + expect(screen.getByRole('link', { name: login.signBtn })).toHaveAttribute( + 'href', + '/signin?redirect_url=%2Fchat%2Fapp', + ) + expect(screen.getByText('2001:db8::12', { selector: 'code' }).closest('p')?.textContent).toBe( + copy['appNotAccessible.ipAddress'].replace('{{ip}}', '2001:db8::12'), + ) + await waitFor(() => expect(document.title).toBe(copy['appNotAccessible.documentTitle'])) + expect(parent.language).toBe('zh-Hans') +}) + +it.each([undefined, '', 'unknown', '203.0.113.1, 192.0.2.1', '999.1.1.1'])( + 'omits the IP sentence for %s', + async (clientIp) => { + render() + await screen.findByRole('heading', { name: enShare['appNotAccessible.title'] }) + expect(screen.queryByText(/Your IP address/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument() + }, +) + +it('changes only the error-page language, retaining the IP and restoring the title on exit', async () => { + const user = userEvent.setup() + const { unmount } = render() + await user.click(await screen.findByRole('button', { name: 'English' })) + await user.click(screen.getByRole('menuitemradio', { name: '日本語 (日本)' })) + expect( + await screen.findByRole('heading', { name: jaShare['appNotAccessible.title'] }), + ).toBeInTheDocument() + await waitFor(() => expect(document.title).toBe(jaShare['appNotAccessible.documentTitle'])) + expect(screen.getByText('203.0.113.8', { selector: 'code' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: jaLogin.signBtn })).toHaveAttribute( + 'href', + '/signin?redirect_url=%2Fchat%2Fapp', + ) + expect(document.cookie).toContain('locale=zh-Hans') + unmount() + expect(document.title).toBe('Previous title') +}) + +it.each([ + '/environment/workflow/app?conversation=123&name=%E6%B5%8B%E8%AF%95', + '/agents/app/access', + '/form/test-token', +])('keeps the current access address %s as the sign-in return target', async (path) => { + window.history.replaceState({}, '', path) + render() + const link = await screen.findByRole('link', { name: enLogin.signBtn }) + const destination = new URL(link.getAttribute('href')!, window.location.origin) + expect(destination.pathname).toBe('/signin') + expect(destination.searchParams.get('redirect_url')).toBe(path) +}) + +it.each([ + [['zh-TW'], 'zh-Hant'], + [['zh-SG'], 'zh-Hans'], + [['xx-XX', 'ja-JP'], 'ja-JP'], + [['xx-XX'], 'en-US'], + [['*', 'not_a_locale'], 'en-US'], +])('matches supported browser preferences %j', (languages, expected) => { + expect(getBrowserLocale(languages)).toBe(expected) +}) diff --git a/web/features/app-access-error/__tests__/state.spec.ts b/web/features/app-access-error/__tests__/state.spec.ts new file mode 100644 index 00000000000000..ca4c158a2380b6 --- /dev/null +++ b/web/features/app-access-error/__tests__/state.spec.ts @@ -0,0 +1,129 @@ +import { + appAccessErrorAtom, + appAccessStore, + captureAppAccessRequest, + captureAppAccessScope, + getAppAccessScopeKey, + handleAppAccessError, + hasAppAccessError, + isAppAccessError, + isAppAccessScopeCurrent, +} from '../state' + +const denied = { code: 'ip_access_denied', client_ip: '203.0.113.42' } +const navigate = (path: string) => { + window.history.replaceState({}, '', path) + return captureAppAccessScope() +} + +describe('WebApp IP denial scope', () => { + beforeEach(() => { + navigate('/') + }) + + it.each([ + ['/workflow/app', 'webapp:default:app'], + ['/chat/app', 'webapp:default:app'], + ['/chatbot/app', 'webapp:default:app'], + ['/completion/app', 'webapp:default:app'], + ['/agent/app', 'webapp:default:app'], + ['/environment/chat/app', 'webapp:environment:app'], + ['/environment/workflow/app', 'webapp:environment:app'], + ['/form/token', 'form:token'], + ])('scopes the public entry %s', (path, expected) => { + expect(navigate(path)?.key).toBe(expected) + }) + + it('resolves a validated sign-in target without scoping console or external redirects', () => { + expect(getAppAccessScopeKey('/webapp-signin', '?redirect_url=%2Fchat%2Fapp')).toBe( + 'webapp:default:app', + ) + expect(getAppAccessScopeKey('/apps', '?redirect_url=%2Fchat%2Fapp')).toBeNull() + expect( + getAppAccessScopeKey('/webapp-signin', '?redirect_url=https://untrusted.example/chat/app'), + ).toBeNull() + }) + + it('keeps one denial across concurrent requests and conversation query changes', () => { + const scope = navigate('/chat/app') + const error = new Response('', { status: 403 }) + expect(handleAppAccessError(403, denied, scope, error)).toBe(true) + const firstDenial = appAccessStore.get(appAccessErrorAtom) + + const sameScope = navigate('/chat/app?conversation=second') + handleAppAccessError(403, denied, sameScope) + + expect(sameScope).toBe(scope) + expect(appAccessStore.get(appAccessErrorAtom)).toBe(firstDenial) + expect(hasAppAccessError(scope)).toBe(true) + expect(isAppAccessError(error)).toBe(true) + }) + + it('ignores a late denial even after navigating back to the same app', () => { + const previousVisit = navigate('/chat/app') + navigate('/chat/other-app') + const currentVisit = navigate('/chat/app') + const error = new Response('', { status: 403 }) + + expect(handleAppAccessError(403, denied, previousVisit, error)).toBe(true) + expect(isAppAccessScopeCurrent(previousVisit)).toBe(false) + expect(hasAppAccessError(currentVisit)).toBe(false) + expect(isAppAccessError(error)).toBe(true) + }) + + it('does not carry a denial into another app or HITL form', () => { + handleAppAccessError(403, denied, navigate('/chat/app')) + const other = navigate('/form/token') + + expect(hasAppAccessError(other)).toBe(false) + expect(appAccessStore.get(appAccessErrorAtom)).toBeNull() + }) + + it.each([ + [401, { code: 'unauthorized' }], + [403, { code: 'web_app_access_denied' }], + [503, { code: 'policy_unavailable' }], + ])('leaves %s %j to its existing error owner', (status, body) => { + const scope = navigate('/workflow/app') + expect(handleAppAccessError(status, body, scope)).toBe(false) + expect(hasAppAccessError(scope)).toBe(false) + }) + + it('does not suppress an unhandled console error with the same error code', () => { + const scope = navigate('/apps') + const error = { status: 403, response: denied } + expect(handleAppAccessError(403, denied, scope, error)).toBe(false) + expect(isAppAccessError(error)).toBe(false) + }) + + it.each(['/app/missing/workflow', '/agents/missing/access'])( + 'leaves a missing console resource at %s to its layout redirect', + (path) => { + const scope = navigate(path) + const error = { status: 404, code: 'app_not_found' } + expect(handleAppAccessError(404, error, scope, error, true)).toBe(false) + expect(isAppAccessError(error)).toBe(false) + expect(hasAppAccessError(scope)).toBe(false) + }, + ) +}) + +describe('application identity request matching', () => { + it.each([ + ['/app/one/workflow', '/console/api/apps/one', false, true, true], + ['/app/one/workflow', '/apps/two', false, false, false], + ['/app/one/workflow', '/apps/one/workflows/draft', false, true, false], + ['/agents/one/access', '/console/api/agent/one', false, true, true], + ['/explore/installed/one', '/installed-apps/one/parameters', false, true, true], + ['/chat/one', '/site', true, true, true], + ['/chat/one', '/webapp/access-mode?appCode=two', true, false, false], + ['/environment/agent/one', '/environment/two/site', true, false, false], + ['/chat/one', '/conversations/missing/messages', true, true, false], + ['/form/token', '/form/human_input/token', true, true, false], + ])('scopes %s requesting %s', (route, url, isPublic, scoped, identity) => { + navigate(route) + const result = captureAppAccessRequest(url, isPublic) + expect(Boolean(result.scope)).toBe(scoped) + expect(result.appIdentity).toBe(identity) + }) +}) diff --git a/web/features/app-access-error/boundary.tsx b/web/features/app-access-error/boundary.tsx new file mode 100644 index 00000000000000..e6fe31a8992fb1 --- /dev/null +++ b/web/features/app-access-error/boundary.tsx @@ -0,0 +1,38 @@ +'use client' + +import type { PropsWithChildren } from 'react' +import { useAtomValueRawSync } from 'jotai/react' +import dynamic from 'next/dynamic' +import { useEffect } from 'react' +import { usePathname, useSearchParams } from '@/next/navigation' +import { + appAccessErrorAtom, + appAccessStore, + captureAppAccessScope, + getAppAccessScopeKey, +} from './state' + +const AppNotAccessible = dynamic(() => import('./page')) + +export default function AppAccessBoundary({ children }: PropsWithChildren) { + const pathname = usePathname() + const searchParams = useSearchParams() + const search = searchParams.toString() + const denial = useAtomValueRawSync(appAccessErrorAtom, { store: appAccessStore }) + const scopeKey = getAppAccessScopeKey( + pathname, + search, + typeof window === 'undefined' ? undefined : window.location.origin, + ) + + // Stay mounted in the root layout to observe leaving application routes, even + // when the next page makes no request that would otherwise reset the scope. + useEffect(() => { + captureAppAccessScope() + }, [pathname, search]) + + if (denial && denial.scope.key === scopeKey) + return + + return children +} diff --git a/web/features/app-access-error/document-constants.ts b/web/features/app-access-error/document-constants.ts new file mode 100644 index 00000000000000..5023785dd40f70 --- /dev/null +++ b/web/features/app-access-error/document-constants.ts @@ -0,0 +1,2 @@ +export const APP_UNAVAILABLE_PATH = '/webapp-unavailable' +export const APP_UNAVAILABLE_IP_HEADER = 'x-dify-app-unavailable-ip' diff --git a/web/features/app-access-error/document-response.ts b/web/features/app-access-error/document-response.ts new file mode 100644 index 00000000000000..adacd039ed49cf --- /dev/null +++ b/web/features/app-access-error/document-response.ts @@ -0,0 +1,137 @@ +// oxlint-disable-next-line no-restricted-imports -- Proxy is the pre-render HTTP boundary, which requires Next's response implementation. +import type { NextRequest } from 'next/server' +import { Buffer } from 'node:buffer' +import { timingSafeEqual } from 'node:crypto' +import { isIP } from 'node:net' +// oxlint-disable-next-line no-restricted-imports -- A rewrite with an explicit status must run before Next starts streaming the document. +import { NextResponse } from 'next/server' +import { env } from '@/env' +import { isAccessMode } from '@/models/access-control' +import { parseWebAppAddress } from '@/service/webapp-address' +import { APP_UNAVAILABLE_IP_HEADER, APP_UNAVAILABLE_PATH } from './document-constants' + +const PROXY_SECRET_HEADER = 'x-dify-webapp-proxy-secret' +const VERIFIED_IP_HEADER = 'x-dify-webapp-client-ip' +const MAX_PREFLIGHT_BYTES = 8192 + +function authenticatedProxyIp(request: NextRequest) { + const expected = env.WEBAPP_ACCESS_PREFLIGHT_PROXY_SECRET + const supplied = request.headers.get(PROXY_SECRET_HEADER) + const ip = request.headers.get(VERIFIED_IP_HEADER) + if (!expected || !supplied || !ip || !isIP(ip)) return undefined + const expectedBytes = Buffer.from(expected) + const suppliedBytes = Buffer.from(supplied) + return expectedBytes.length === suppliedBytes.length && + timingSafeEqual(expectedBytes, suppliedBytes) + ? ip + : undefined +} + +async function readPreflightPayload(response: Response): Promise { + if (!response.headers.get('content-type')?.toLowerCase().startsWith('application/json')) + throw new Error('Unexpected preflight content type') + const reader = response.body?.getReader() + if (!reader) throw new Error('Empty preflight response') + const chunks: Uint8Array[] = [] + let length = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + length += value.byteLength + if (length > MAX_PREFLIGHT_BYTES) throw new Error('Preflight response too large') + chunks.push(value) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) + } finally { + await reader.cancel() + } +} + +/** A protocol failure is not evidence that an application does not exist. */ +function unavailable() { + return new NextResponse(null, { status: 503, headers: { 'Cache-Control': 'no-store' } }) +} + +function notFoundDocument(request: NextRequest, requestHeaders: Headers, ip?: string) { + const destination = request.nextUrl.clone() + destination.pathname = APP_UNAVAILABLE_PATH + destination.search = '' + // Keep only Next's transport-owned cache-busting value for RSC navigation. + // Removing it causes Next's RSC validation to redirect before rendering. + const rsc = request.nextUrl.searchParams.get('_rsc') + if (rsc !== null) destination.searchParams.set('_rsc', rsc) + requestHeaders.set('x-dify-pathname', APP_UNAVAILABLE_PATH) + requestHeaders.set('x-dify-search', '') + if (ip) requestHeaders.set(APP_UNAVAILABLE_IP_HEADER, ip) + return NextResponse.rewrite(destination, { + status: 404, + headers: { 'Cache-Control': 'no-store' }, + request: { headers: requestHeaders }, + }) +} + +/** + * Only the configured ingress can attest the visitor IP. The Gateway remains + * the authorization owner; neither browser forwarding headers nor a direct + * Core request can substitute for its decision. + */ +export async function getWebAppDocumentResponse(request: NextRequest, requestHeaders: Headers) { + // These values must never survive from a browser request or reach page props. + requestHeaders.delete(APP_UNAVAILABLE_IP_HEADER) + requestHeaders.delete(PROXY_SECRET_HEADER) + requestHeaders.delete(VERIFIED_IP_HEADER) + + const { pathname } = request.nextUrl + if (pathname === APP_UNAVAILABLE_PATH) return notFoundDocument(request, requestHeaders) + + const address = parseWebAppAddress(pathname) + if (!address || address.kind !== 'default' || !env.WEBAPP_ACCESS_PREFLIGHT_URL) return undefined + + const clientIp = authenticatedProxyIp(request) + if (!clientIp) return unavailable() + + try { + const url = new URL(env.WEBAPP_ACCESS_PREFLIGHT_URL) + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.hash || + url.search + ) + return unavailable() + url.searchParams.set('appCode', decodeURIComponent(address.code)) + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json', 'X-Forwarded-For': clientIp }, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(5000), + }) + if (response.status !== 200 && response.status !== 404) return unavailable() + const payload = await readPreflightPayload(response) + if (typeof payload !== 'object' || payload === null) return unavailable() + if ( + response.status === 200 && + 'accessMode' in payload && + typeof payload.accessMode === 'string' && + isAccessMode(payload.accessMode) + ) + return undefined + if ( + response.status === 404 && + 'code' in payload && + payload.code === 'app_not_found' && + 'status' in payload && + payload.status === 404 && + 'message' in payload && + payload.message === 'App not found.' + ) + return notFoundDocument(request, requestHeaders, clientIp) + return unavailable() + } catch { + // Do not log the URL, ingress proof, visitor IP, or upstream error body. + return unavailable() + } +} diff --git a/web/features/app-access-error/document.ts b/web/features/app-access-error/document.ts new file mode 100644 index 00000000000000..4c6f7a2f014afa --- /dev/null +++ b/web/features/app-access-error/document.ts @@ -0,0 +1,15 @@ +import Negotiator from 'negotiator' +import { APP_UNAVAILABLE_PATH } from './document-constants' +import { getBrowserLocale } from './locale' + +/** The proxy-owned error route skips application bootstrap and uses the visitor locale. */ +export function getAppAccessErrorDocument(requestHeaders: Pick) { + if (requestHeaders.get('x-dify-pathname') !== APP_UNAVAILABLE_PATH) return undefined + return { + locale: getBrowserLocale( + new Negotiator({ + headers: { 'accept-language': requestHeaders.get('accept-language') ?? '' }, + }).languages(), + ), + } +} diff --git a/web/features/app-access-error/locale-menu.tsx b/web/features/app-access-error/locale-menu.tsx new file mode 100644 index 00000000000000..fca5473bb30312 --- /dev/null +++ b/web/features/app-access-error/locale-menu.tsx @@ -0,0 +1,57 @@ +import type { Locale } from '@/i18n' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuRadioItemIndicator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { languages } from '@/i18n/language' + +const supportedLanguages = languages.filter((language) => language.supported) + +export default function LocaleMenu({ + locale, + onChange, +}: { + locale: Locale + onChange: (locale: Locale) => void +}) { + const selectedLanguage = supportedLanguages.find((language) => language.value === locale) + const selectedLanguageName = + selectedLanguage?.value === 'en-US' ? selectedLanguage.prompt_name : selectedLanguage?.name + + return ( + + + } + > + + {/* oxlint-disable-next-line dify/require-title-for-truncated-text -- The language menu exposes the full selected label through pointer, keyboard, and touch. */} + {selectedLanguageName} + + + + + value={locale} + onValueChange={(value) => { + onChange(value) + }} + > + {supportedLanguages.map((language) => ( + key={language.value} value={language.value} closeOnClick> + {language.name} + + + ))} + + + + ) +} diff --git a/web/features/app-access-error/locale.ts b/web/features/app-access-error/locale.ts new file mode 100644 index 00000000000000..f5a0425ba12338 --- /dev/null +++ b/web/features/app-access-error/locale.ts @@ -0,0 +1,14 @@ +import type { Locale } from '@/i18n' +import { match } from '@formatjs/intl-localematcher' +import { LanguagesSupported } from '@/i18n/language' + +export function getBrowserLocale(preferences: readonly string[]): Locale { + const valid = preferences.filter((language) => { + try { + return Intl.getCanonicalLocales(language).length > 0 + } catch { + return false + } + }) + return match(valid, LanguagesSupported, 'en-US') as Locale +} diff --git a/web/features/app-access-error/page.stories.tsx b/web/features/app-access-error/page.stories.tsx new file mode 100644 index 00000000000000..fa8e36869ab5a2 --- /dev/null +++ b/web/features/app-access-error/page.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { ThemeProvider } from 'next-themes' +import AppNotAccessible from './page' + +const meta = { + title: 'App/App Not Accessible', + component: AppNotAccessible, + parameters: { + layout: 'fullscreen', + nextjs: { appDirectory: true, navigation: { pathname: '/chat/app' } }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const WithoutIP: Story = {} +export const IPv4: Story = { args: { clientIp: '203.0.113.42' } } +export const IPv6: Story = { args: { clientIp: '2001:db8:1234:5678:90ab:cdef:1234:5678' } } + +export const NarrowIframe: Story = { + render: () => ( +