refactor: migrate network access group APIs

This commit is contained in:
hjlarry committed 2026-09-10 14:25:38 +08:00
1 parent 4f82c193bc
commit e8b965a3c3
16 files changed
+1841 -987

No files matched your search

+15
View File
@@ -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:init-validation-service-boundary]
name = Initialization validation application service is framework and persistence neutral
type = forbidden
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Literal, Self
from typing import Any, Self
from uuid import UUID
from flask_restx import Resource
@@ -8,34 +8,24 @@ from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, Not
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.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_paid_plan_required,
is_cloud_edition_billing_paid_plan,
model_validate,
only_edition_cloud,
setup_required,
with_current_tenant_id,
with_current_user,
)
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 enums import DeploymentEdition
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from models import Account, App, AppMode, TenantAccountRole
from services.billing_service import BillingService, NetworkAccessGroupUpstreamError
from services.network_access_group_service import NetworkAccessGroupService
NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"]
_ACCESS_POINTS_BY_APP_MODE: dict[AppMode, tuple[NetworkAccessPoint, ...]] = {
AppMode.WORKFLOW: ("webapp", "service_api", "mcp", "trigger"),
AppMode.ADVANCED_CHAT: ("webapp", "service_api", "mcp"),
AppMode.CHAT: ("webapp", "service_api", "mcp"),
AppMode.COMPLETION: ("webapp", "service_api", "mcp"),
AppMode.AGENT_CHAT: ("webapp", "service_api", "mcp"),
AppMode.AGENT: ("webapp", "service_api"),
}
from machinery.context import RequestContext
from services.network_access_group_service import (
NetworkAccessGroupAccessDeniedError,
NetworkAccessGroupAppNotFoundError,
NetworkAccessGroupEntitlementUnavailableError,
NetworkAccessGroupError,
NetworkAccessGroupUnsupportedAccessPointsError,
NetworkAccessGroupUnsupportedAppModeError,
NetworkAccessGroupUpstreamError,
NetworkAccessPoint,
)
class NetworkAccessGroupCreatePayload(BaseModel):
@@ -184,59 +174,6 @@ register_response_schema_models(
)
def _available_access_points(app_model: App) -> list[NetworkAccessPoint]:
app_mode = app_model.mode if isinstance(app_model.mode, AppMode) else AppMode.value_of(str(app_model.mode))
available = _ACCESS_POINTS_BY_APP_MODE.get(app_mode)
if available is None:
raise BadRequest(f"Network access control is not supported for app mode '{app_mode.value}'.")
return list(available)
def _validate_app_access_points(
requested: list[NetworkAccessPoint],
available: list[NetworkAccessPoint],
) -> None:
unsupported = sorted(set(requested).difference(available))
if unsupported:
raise BadRequest(f"Unsupported access points for this app: {', '.join(unsupported)}.")
def _effective_entitlement(tenant_id: str, upstream_entitled: object) -> bool:
return bool(upstream_entitled) and is_cloud_edition_billing_paid_plan(tenant_id)
def _normalize_app_binding_defaults(payload: dict) -> None:
"""Materialize ProtoJSON defaults so the Console response stays stable."""
payload.setdefault("binding", None)
binding = payload.get("binding")
if not isinstance(binding, dict):
return
binding.setdefault("enabled", False)
if "access_points" not in binding and "accessPoints" not in binding:
binding["access_points"] = []
def _limit_binding_access_points(payload: dict, available: list[NetworkAccessPoint]) -> None:
"""Hide stale scopes which are not real access points for the current App mode."""
binding = payload.get("binding")
if not isinstance(binding, dict):
return
raw_access_points = binding.get("access_points", binding.get("accessPoints", []))
if not isinstance(raw_access_points, list):
return
binding["access_points"] = [access_point for access_point in raw_access_points if access_point in available]
binding.pop("accessPoints", None)
def _ensure_workspace_admin_or_owner(current_user: Account) -> None:
"""Authorize management against the persisted current-workspace membership."""
if not TenantAccountRole.is_privileged_role(current_user.current_role):
raise Forbidden("Only workspace owners and administrators can manage network access groups.")
def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception:
if exc.reason == "INVALID_SECRET_KEY":
return ServiceUnavailable("Network access group service authentication failed.")
@@ -260,7 +197,23 @@ def _translate_upstream_error(exc: NetworkAccessGroupUpstreamError) -> Exception
return BadGateway("Unexpected response from the network access group service.")
def _serialize_response(response_model: type[ResponseModel], payload: dict):
def _translate_service_error(exc: NetworkAccessGroupError) -> Exception:
if isinstance(exc, NetworkAccessGroupUpstreamError):
return _translate_upstream_error(exc)
if isinstance(exc, NetworkAccessGroupAccessDeniedError):
return Forbidden("Only workspace owners and administrators can manage network access groups.")
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)}.")
raise TypeError(f"Unsupported network access group error: {type(exc).__name__}")
def _serialize_response(response_model: type[ResponseModel], payload: dict[str, Any]) -> dict[str, Any]:
try:
return dump_response(response_model, payload)
except ValidationError as exc:
@@ -274,20 +227,12 @@ class CurrentWorkspaceNetworkAccessGroupsApi(Resource):
"Workspace network access groups retrieved successfully",
console_ns.models[NetworkAccessGroupListResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
_ensure_workspace_admin_or_owner(current_user)
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext) -> dict[str, Any]:
try:
payload = BillingService.list_network_access_groups(current_tenant_id, current_user.id)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled"))
NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id)
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__])
@@ -296,32 +241,23 @@ class CurrentWorkspaceNetworkAccessGroupsApi(Resource):
"Workspace network access group created successfully",
console_ns.models[NetworkAccessGroupMutationResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@cloud_edition_billing_paid_plan_required
@with_current_user
@with_current_tenant_id
@model_validate(NetworkAccessGroupCreatePayload)
def post(
self,
req_data: NetworkAccessGroupCreatePayload,
current_tenant_id: str,
current_user: Account,
):
_ensure_workspace_admin_or_owner(current_user)
request_context: RequestContext,
) -> tuple[dict[str, Any], int]:
try:
payload = BillingService.create_network_access_group(
current_tenant_id,
payload = application_services().network_access_groups.create_group(
request_context,
name=req_data.name,
description=req_data.description,
allowed_cidrs=req_data.allowed_cidrs,
actor_account_id=current_user.id,
)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id)
except NetworkAccessGroupError as exc:
raise _translate_service_error(exc) from exc
return _serialize_response(NetworkAccessGroupMutationResponse, payload), 201
@@ -332,19 +268,15 @@ class CurrentWorkspaceNetworkAccessGroupApi(Resource):
"Workspace network access group retrieved successfully",
console_ns.models[NetworkAccessGroupMutationResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, group_id: UUID):
_ensure_workspace_admin_or_owner(current_user)
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext, group_id: UUID) -> dict[str, Any]:
try:
payload = BillingService.get_network_access_group(current_tenant_id, str(group_id), current_user.id)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id)
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, payload)
@console_ns.expect(console_ns.models[NetworkAccessGroupUpdatePayload.__name__])
@@ -353,35 +285,26 @@ class CurrentWorkspaceNetworkAccessGroupApi(Resource):
"Workspace network access group updated successfully",
console_ns.models[NetworkAccessGroupMutationResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@cloud_edition_billing_paid_plan_required
@with_current_user
@with_current_tenant_id
@model_validate(NetworkAccessGroupUpdatePayload)
def put(
self,
req_data: NetworkAccessGroupUpdatePayload,
current_tenant_id: str,
current_user: Account,
request_context: RequestContext,
group_id: UUID,
):
_ensure_workspace_admin_or_owner(current_user)
) -> dict[str, Any]:
try:
payload = BillingService.update_network_access_group(
current_tenant_id,
str(group_id),
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,
actor_account_id=current_user.id,
)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
NetworkAccessGroupService.enrich_app_references(payload, current_tenant_id)
except NetworkAccessGroupError as exc:
raise _translate_service_error(exc) from exc
return _serialize_response(NetworkAccessGroupMutationResponse, payload)
@console_ns.doc(params=query_params_from_model(NetworkAccessGroupDeleteQuery))
@@ -390,31 +313,23 @@ class CurrentWorkspaceNetworkAccessGroupApi(Resource):
"Workspace network access group deleted successfully",
console_ns.models[NetworkAccessGroupDeleteResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@cloud_edition_billing_paid_plan_required
@with_current_user
@with_current_tenant_id
@model_validate(NetworkAccessGroupDeleteQuery)
def delete(
self,
req_data: NetworkAccessGroupDeleteQuery,
current_tenant_id: str,
current_user: Account,
request_context: RequestContext,
group_id: UUID,
):
_ensure_workspace_admin_or_owner(current_user)
) -> dict[str, Any]:
try:
payload = BillingService.delete_network_access_group(
current_tenant_id,
str(group_id),
payload = application_services().network_access_groups.delete_group(
request_context,
group_id=str(group_id),
expected_version=req_data.expected_version,
actor_account_id=current_user.id,
)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
except NetworkAccessGroupError as exc:
raise _translate_service_error(exc) from exc
return _serialize_response(NetworkAccessGroupDeleteResponse, payload)
@@ -425,28 +340,15 @@ class AppNetworkAccessGroupApi(Resource):
"App network access group binding retrieved successfully",
console_ns.models[AppNetworkAccessGroupResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@with_current_user
@with_current_tenant_id
@get_app_model
def get(self, current_tenant_id: str, current_user: Account, app_model: App):
_ensure_workspace_admin_or_owner(current_user)
available_access_points = _available_access_points(app_model)
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
def get(self, request_context: RequestContext, app_id: UUID) -> dict[str, Any]:
try:
payload = BillingService.get_app_network_access_group(
current_tenant_id,
str(app_model.id),
current_user.id,
payload = application_services().network_access_groups.get_app_binding(
request_context,
app_id=str(app_id),
)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
_normalize_app_binding_defaults(payload)
_limit_binding_access_points(payload, available_access_points)
payload["entitled"] = _effective_entitlement(current_tenant_id, payload.get("entitled"))
payload["available_access_points"] = available_access_points
except NetworkAccessGroupError as exc:
raise _translate_service_error(exc) from exc
return _serialize_response(AppNetworkAccessGroupResponse, payload)
@console_ns.expect(console_ns.models[AppNetworkAccessGroupUpdatePayload.__name__])
@@ -455,38 +357,24 @@ class AppNetworkAccessGroupApi(Resource):
"App network access group binding updated successfully",
console_ns.models[AppNetworkAccessGroupMutationResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@only_edition_cloud
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
@cloud_edition_billing_paid_plan_required
@with_current_user
@with_current_tenant_id
@get_app_model
@model_validate(AppNetworkAccessGroupUpdatePayload)
def put(
self,
req_data: AppNetworkAccessGroupUpdatePayload,
current_tenant_id: str,
current_user: Account,
app_model: App,
):
_ensure_workspace_admin_or_owner(current_user)
available_access_points = _available_access_points(app_model)
_validate_app_access_points(req_data.access_points, available_access_points)
request_context: RequestContext,
app_id: UUID,
) -> dict[str, Any]:
try:
payload = BillingService.update_app_network_access_group(
current_tenant_id,
str(app_model.id),
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,
actor_account_id=current_user.id,
)
except NetworkAccessGroupUpstreamError as exc:
raise _translate_upstream_error(exc) from exc
_normalize_app_binding_defaults(payload)
_limit_binding_access_points(payload, available_access_points)
payload["available_access_points"] = available_access_points
except NetworkAccessGroupError as exc:
raise _translate_service_error(exc) from exc
return _serialize_response(AppNetworkAccessGroupMutationResponse, payload)
@@ -50,6 +50,7 @@ from repositories.file_grant_repository import FileGrantRepository
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_access_token_repository import SQLAlchemyOAuthAccessTokenRepository
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository
@@ -163,6 +164,11 @@ from services.human_input_file_upload_service import HumanInputFileUploadService
from services.init_validation_service import InitValidationService
from services.inner_mail_service import InnerMailService
from services.message_file_preview_service import MessageFilePreviewService
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.notion_data_source_gateway import NotionDataSourceGateway
@@ -278,6 +284,7 @@ class ApplicationServices:
oauth_server: OAuthServerService
init_validation: InitValidationService
notifications: NotificationService
network_access_groups: NetworkAccessGroupService
step_by_step_tour: StepByStepTourService
partner_tenant_bindings: PartnerTenantBindingService
recommended_app_queries: RecommendedAppQueryService
@@ -696,6 +703,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_query_repository,
entitlement=BillingNetworkAccessGroupEntitlementGateway(),
),
step_by_step_tour=StepByStepTourService(
accounts=accounts,
states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client),
@@ -0,0 +1,76 @@
"""SQLAlchemy read repository for network access group App projections."""
from collections.abc import Sequence
from typing import override
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from models import App
from models.agent import AgentScope
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
return self._to_record(app)
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 = session.scalars(
select(App).where(
App.tenant_id == workspace_id,
App.id.in_(app_ids),
App.status == "normal",
)
).all()
return tuple(self._to_record(app) for app in apps)
except SQLAlchemyError as exc:
raise NetworkAccessGroupAppQueryError from exc
@staticmethod
def _to_record(app: App) -> 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,
)
@@ -58,6 +58,15 @@ class WorkspaceQueryRepository(
with self._session_factory() as session:
return tuple(session.scalars(stmt).all())
def get_role_for_account(self, *, workspace_id: str, account_id: str) -> str | None:
stmt = select(TenantAccountJoin.role).where(
TenantAccountJoin.tenant_id == workspace_id,
TenantAccountJoin.account_id == account_id,
)
with self._session_factory() as session:
role = session.scalar(stmt)
return role.value if role is not None else None
@override
def list_account_access_workspaces(self, account_id: str) -> tuple[AccountWorkspaceSnapshot, ...]:
"""List every membership for the OpenAPI account identity response.
-225
View File
@@ -10,7 +10,6 @@ from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fix
from typing_extensions import deprecated
from werkzeug.exceptions import InternalServerError
from configs import dify_config
from core.helper.http_client_pooling import get_pooled_http_client
from enums import CloudPlan
from extensions.ext_redis import redis_client
@@ -216,15 +215,6 @@ class DismissNotificationDict(TypedDict):
success: bool
class NetworkAccessGroupUpstreamError(Exception):
"""Failure returned by the SaaS network-access-group control plane."""
def __init__(self, status_code: int, reason: str | None = None):
self.status_code = status_code
self.reason = reason
super().__init__(f"network access group upstream returned HTTP {status_code}")
class BillingService:
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
quota_base_url = os.environ.get("BILLING_QUOTA_API_URL") or base_url
@@ -443,221 +433,6 @@ class BillingService:
params = {"tenant_id": tenant_id, "feature_key": feature_key}
return cls._send_request("GET", "/billing/tenant_feature_plan/usage", params=params)
@classmethod
def list_network_access_groups(cls, tenant_id: str, actor_account_id: str) -> dict[str, Any]:
"""List the reusable network access groups owned by one workspace."""
return cls._send_network_access_group_request(
"GET",
f"/tenants/{tenant_id}/network-access-groups",
params={"actor_account_id": actor_account_id},
)
@classmethod
def create_network_access_group(
cls,
tenant_id: str,
*,
name: str,
description: str,
allowed_cidrs: list[str],
actor_account_id: str,
) -> dict[str, Any]:
"""Create a reusable group; the Console BFF injects the authenticated actor."""
return cls._send_network_access_group_request(
"POST",
f"/tenants/{tenant_id}/network-access-groups",
payload_json={
"name": name,
"description": description,
"allowed_cidrs": allowed_cidrs,
"actor_account_id": actor_account_id,
},
)
@classmethod
def get_network_access_group(
cls,
tenant_id: str,
group_id: str,
actor_account_id: str,
) -> dict[str, Any]:
"""Fetch one reusable group scoped to its owning workspace."""
return cls._send_network_access_group_request(
"GET",
f"/tenants/{tenant_id}/network-access-groups/{group_id}",
params={"actor_account_id": actor_account_id},
)
@classmethod
def update_network_access_group(
cls,
tenant_id: str,
group_id: str,
*,
name: str,
description: str,
allowed_cidrs: list[str],
expected_version: int,
actor_account_id: str,
) -> dict[str, Any]:
"""Replace a reusable group using optimistic concurrency control."""
return cls._send_network_access_group_request(
"PUT",
f"/tenants/{tenant_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,
},
)
@classmethod
def delete_network_access_group(
cls,
tenant_id: str,
group_id: str,
*,
expected_version: int,
actor_account_id: str,
) -> dict[str, Any]:
"""Delete a reusable group using optimistic concurrency control."""
return cls._send_network_access_group_request(
"DELETE",
f"/tenants/{tenant_id}/network-access-groups/{group_id}",
params={
"expected_version": expected_version,
"actor_account_id": actor_account_id,
},
)
@classmethod
def get_app_network_access_group(
cls,
tenant_id: str,
app_id: str,
actor_account_id: str,
) -> dict[str, Any]:
"""Fetch the reusable group currently assigned to one tenant-scoped app."""
return cls._send_network_access_group_request(
"GET",
f"/tenants/{tenant_id}/apps/{app_id}/network-access-group",
params={"actor_account_id": actor_account_id},
)
@classmethod
def update_app_network_access_group(
cls,
tenant_id: str,
app_id: str,
*,
enabled: bool,
group_id: str | None,
access_points: list[str],
expected_version: int,
actor_account_id: str,
) -> dict[str, Any]:
"""Atomically replace one tenant-scoped App's complete network access configuration."""
return cls._send_network_access_group_request(
"PUT",
f"/tenants/{tenant_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,
},
)
@classmethod
def cleanup_app_network_access_group_binding(cls, tenant_id: str, app_id: str) -> dict[str, Any]:
"""Remove an App binding through the internal App lifecycle endpoint.
This must only be called by the post-commit App deletion task. Account
or workspace-member deletion does not own App bindings and must not use
this operation.
"""
return cls._send_network_access_group_request(
"DELETE",
f"/tenants/{tenant_id}/apps/{app_id}/network-access-group-binding",
)
@classmethod
def _send_network_access_group_request(
cls,
method: Literal["GET", "POST", "PUT", "DELETE"],
endpoint: str,
*,
payload_json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call the SaaS group API while preserving its status for the Console boundary."""
try:
response = cls._send_network_access_group_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 NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) from exc
if not isinstance(payload, dict):
raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY)
return payload
@classmethod
@retry(
wait=wait_fixed(2),
stop=stop_before_delay(10),
retry=retry_if_exception_type(httpx.RequestError),
reraise=True,
)
def _send_network_access_group_http_request(
cls,
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": cls.secret_key}
network_access_base_url = dify_config.NETWORK_ACCESS_API_URL.strip() or cls.base_url
return _http_client.request(
method,
f"{network_access_base_url.rstrip('/')}{endpoint}",
json=payload_json,
params=params,
headers=headers,
follow_redirects=True,
)
@classmethod
def _send_quota_request(
cls, method: Literal["GET", "POST", "DELETE", "PUT"], endpoint: str, json=None, params=None
@@ -0,0 +1,235 @@
"""HTTP and Billing adapters used by the network access group service."""
import json
from typing import Any, Literal, override
import httpx
from pydantic import ValidationError
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.network_access_group_service import (
NetworkAccessGroupControlPlane,
NetworkAccessGroupEntitlement,
NetworkAccessGroupEntitlementUnavailableError,
NetworkAccessGroupUpstreamError,
)
_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) -> dict[str, Any]:
return self._send_request(
"GET",
f"/tenants/{workspace_id}/network-access-groups",
params={"actor_account_id": actor_account_id},
)
@override
def create_group(
self,
workspace_id: str,
*,
name: str,
description: str,
allowed_cidrs: list[str],
actor_account_id: str,
) -> dict[str, Any]:
return 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,
},
)
@override
def get_group(self, workspace_id: str, group_id: str, actor_account_id: str) -> dict[str, Any]:
return self._send_request(
"GET",
f"/tenants/{workspace_id}/network-access-groups/{group_id}",
params={"actor_account_id": actor_account_id},
)
@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,
) -> dict[str, Any]:
return 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,
},
)
@override
def delete_group(
self,
workspace_id: str,
group_id: str,
*,
expected_version: int,
actor_account_id: str,
) -> dict[str, Any]:
return self._send_request(
"DELETE",
f"/tenants/{workspace_id}/network-access-groups/{group_id}",
params={
"expected_version": expected_version,
"actor_account_id": actor_account_id,
},
)
@override
def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> dict[str, Any]:
return self._send_request(
"GET",
f"/tenants/{workspace_id}/apps/{app_id}/network-access-group",
params={"actor_account_id": actor_account_id},
)
@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,
) -> dict[str, Any]:
return 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,
},
)
@override
def cleanup_app_binding(self, workspace_id: str, app_id: str) -> dict[str, Any]:
return self._send_request(
"DELETE",
f"/tenants/{workspace_id}/apps/{app_id}/network-access-group-binding",
)
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 NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY) from exc
if not isinstance(payload, dict):
raise NetworkAccessGroupUpstreamError(httpx.codes.BAD_GATEWAY)
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)
+343 -23
View File
@@ -1,23 +1,345 @@
"""Application service for workspace network access groups."""
import logging
from typing import Any
from collections.abc import Sequence
from typing import Any, Literal, NamedTuple, Protocol
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from extensions.ext_database import db
from models import App
from machinery.context import RequestContext
logger = logging.getLogger(__name__)
NetworkAccessPoint = Literal["webapp", "service_api", "mcp", "trigger"]
_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 is not a persisted owner or administrator of the workspace."""
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 NetworkAccessGroupAppQueryError(Exception):
"""The local App read model could not be queried."""
class NetworkAccessGroupAppRecord(NamedTuple):
id: str
mode: str
name: str
icon: str | None
icon_type: str | None
icon_background: str | None
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_role_for_account(self, *, workspace_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) -> dict[str, Any]: ...
def create_group(
self,
workspace_id: str,
*,
name: str,
description: str,
allowed_cidrs: list[str],
actor_account_id: str,
) -> dict[str, Any]: ...
def get_group(self, workspace_id: str, group_id: str, actor_account_id: str) -> dict[str, Any]: ...
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,
) -> dict[str, Any]: ...
def delete_group(
self,
workspace_id: str,
group_id: str,
*,
expected_version: int,
actor_account_id: str,
) -> dict[str, Any]: ...
def get_app_binding(self, workspace_id: str, app_id: str, actor_account_id: str) -> dict[str, Any]: ...
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,
) -> dict[str, Any]: ...
def cleanup_app_binding(self, workspace_id: str, app_id: str) -> dict[str, Any]: ...
class NetworkAccessGroupService:
"""Dify-side orchestration for SaaS network access group responses."""
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) -> dict[str, Any]:
self._ensure_workspace_admin_or_owner(context)
payload = self._control_plane.list_groups(context.active_workspace_id, context.account_id)
payload["entitled"] = self._effective_entitlement(
context.active_workspace_id,
payload.get("entitled"),
)
return self._enrich_app_references(payload, context.active_workspace_id)
def create_group(
self,
context: RequestContext,
*,
name: str,
description: str,
allowed_cidrs: list[str],
) -> dict[str, Any]:
self._ensure_workspace_admin_or_owner(context)
payload = 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_app_references(payload, context.active_workspace_id)
def get_group(self, context: RequestContext, *, group_id: str) -> dict[str, Any]:
self._ensure_workspace_admin_or_owner(context)
payload = self._control_plane.get_group(
context.active_workspace_id,
group_id,
context.account_id,
)
return self._enrich_app_references(payload, context.active_workspace_id)
def update_group(
self,
context: RequestContext,
*,
group_id: str,
name: str,
description: str,
allowed_cidrs: list[str],
expected_version: int,
) -> dict[str, Any]:
self._ensure_workspace_admin_or_owner(context)
payload = 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_app_references(payload, context.active_workspace_id)
def delete_group(
self,
context: RequestContext,
*,
group_id: str,
expected_version: int,
) -> dict[str, Any]:
self._ensure_workspace_admin_or_owner(context)
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) -> dict[str, Any]:
app = self._get_manageable_app(context.active_workspace_id, app_id)
self._ensure_workspace_admin_or_owner(context)
available_access_points = self._available_access_points(app.mode)
payload = self._control_plane.get_app_binding(
context.active_workspace_id,
app.id,
context.account_id,
)
self._normalize_app_binding_defaults(payload)
self._limit_binding_access_points(payload, available_access_points)
payload["entitled"] = self._effective_entitlement(
context.active_workspace_id,
payload.get("entitled"),
)
payload["available_access_points"] = available_access_points
return payload
def update_app_binding(
self,
context: RequestContext,
*,
app_id: str,
enabled: bool,
group_id: str | None,
access_points: list[NetworkAccessPoint],
expected_version: int,
) -> dict[str, Any]:
app = self._get_manageable_app(context.active_workspace_id, app_id)
self._ensure_workspace_admin_or_owner(context)
available_access_points = self._available_access_points(app.mode)
self._validate_app_access_points(access_points, available_access_points)
payload = 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,
)
self._normalize_app_binding_defaults(payload)
self._limit_binding_access_points(payload, available_access_points)
payload["available_access_points"] = available_access_points
return payload
def cleanup_app_binding(self, *, workspace_id: str, app_id: str) -> dict[str, Any]:
return self._control_plane.cleanup_app_binding(workspace_id, app_id)
def _ensure_workspace_admin_or_owner(self, context: RequestContext) -> None:
role = self._memberships.get_role_for_account(
workspace_id=context.active_workspace_id,
account_id=context.account_id,
)
if role not in {"owner", "admin"}:
raise NetworkAccessGroupAccessDeniedError
def _effective_entitlement(self, workspace_id: str, upstream_entitled: object) -> bool:
return bool(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 enrich_app_references(
def _available_access_points(app_mode: str) -> list[NetworkAccessPoint]:
available = _ACCESS_POINTS_BY_APP_MODE.get(app_mode)
if available is None:
raise NetworkAccessGroupUnsupportedAppModeError(app_mode)
return list(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 _normalize_app_binding_defaults(payload: dict[str, Any]) -> None:
"""Materialize ProtoJSON defaults so the Console response stays stable."""
payload.setdefault("binding", None)
binding = payload.get("binding")
if not isinstance(binding, dict):
return
binding.setdefault("enabled", False)
if "access_points" not in binding and "accessPoints" not in binding:
binding["access_points"] = []
@staticmethod
def _limit_binding_access_points(
payload: dict[str, Any],
tenant_id: str,
) -> dict[str, Any]:
available: Sequence[NetworkAccessPoint],
) -> None:
"""Hide stale scopes which are not real access points for the current App mode."""
binding = payload.get("binding")
if not isinstance(binding, dict):
return
raw_access_points = binding.get("access_points", binding.get("accessPoints", []))
if not isinstance(raw_access_points, list):
return
binding["access_points"] = [access_point for access_point in raw_access_points if access_point in available]
binding.pop("accessPoints", None)
def _enrich_app_references(self, payload: dict[str, Any], workspace_id: str) -> dict[str, Any]:
"""Attach tenant-scoped App display metadata to one or more group payloads."""
raw_groups: list[object] = []
@@ -56,27 +378,25 @@ class NetworkAccessGroupService:
return payload
try:
app_models = db.session.scalars(
select(App).where(
App.tenant_id == tenant_id,
App.id.in_(app_ids),
App.status == "normal",
)
).all()
except SQLAlchemyError:
logger.exception("Failed to enrich network access Policy App references", extra={"tenant_id": tenant_id})
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},
)
for group in groups:
group["apps"] = []
return payload
apps_by_id = {
str(app.id): {
"id": str(app.id),
app.id: {
"id": app.id,
"name": app.name,
"icon": app.icon,
"icon_type": app.icon_type.value if app.icon_type is not None else None,
"icon_type": app.icon_type,
"icon_background": app.icon_background,
}
for app in app_models
for app in app_records
}
for group in groups:
group_app_ids = group["app_ids"] if isinstance(group["app_ids"], list) else []
@@ -52,7 +52,6 @@ from models.workflow import (
)
from repositories.factory import DifyAPIRepositoryFactory
from services.api_token_service import ApiTokenCache
from services.billing_service import BillingService
logger = logging.getLogger(__name__)
@@ -76,7 +75,14 @@ def cleanup_app_network_access_group_binding_task(self, tenant_id: str, app_id:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return
try:
BillingService.cleanup_app_network_access_group_binding(tenant_id, app_id)
# 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(
@@ -1,16 +1,17 @@
from collections.abc import Iterator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from datetime import UTC, datetime
from inspect import unwrap
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from flask import Flask
from pydantic import ValidationError
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.exceptions import BadGateway, BadRequest, Conflict, Forbidden, HTTPException, NotFound, ServiceUnavailable
from controllers.console.app.error import AppNotFoundError
from controllers.console.workspace.network_access_group import (
AppNetworkAccessGroupApi,
AppNetworkAccessGroupUpdatePayload,
@@ -19,14 +20,19 @@ from controllers.console.workspace.network_access_group import (
NetworkAccessGroupCreatePayload,
NetworkAccessGroupDeleteQuery,
NetworkAccessGroupUpdatePayload,
_available_access_points,
_effective_entitlement,
_limit_binding_access_points,
_translate_service_error,
_translate_upstream_error,
)
from models import App, AppMode, TenantAccountRole
from services.billing_service import BillingService, NetworkAccessGroupUpstreamError
from services.network_access_group_service import NetworkAccessGroupService
from machinery.context import RequestContext
from services.network_access_group_service import (
NetworkAccessGroupAccessDeniedError,
NetworkAccessGroupAppNotFoundError,
NetworkAccessGroupEntitlementUnavailableError,
NetworkAccessGroupError,
NetworkAccessGroupUnsupportedAccessPointsError,
NetworkAccessGroupUnsupportedAppModeError,
NetworkAccessGroupUpstreamError,
)
TENANT_ID = "11111111-1111-4111-8111-111111111111"
ACCOUNT_ID = "22222222-2222-4222-8222-222222222222"
@@ -35,15 +41,20 @@ GROUP_ID = "44444444-4444-4444-8444-444444444444"
BINDING_ID = "55555555-5555-4555-8555-555555555555"
def _current_user(role: TenantAccountRole = TenantAccountRole.OWNER) -> SimpleNamespace:
return SimpleNamespace(id=ACCOUNT_ID, current_role=role)
def _request_context() -> RequestContext:
return RequestContext(
request_id="request-id",
trace_id=None,
account_id=ACCOUNT_ID,
active_workspace_id=TENANT_ID,
)
@pytest.fixture(autouse=True)
def paid_entitlement() -> Iterator[None]:
@contextmanager
def _application_services(service: MagicMock) -> Generator[None]:
with patch(
"controllers.console.workspace.network_access_group._effective_entitlement",
side_effect=lambda _tenant_id, upstream: bool(upstream),
"controllers.console.workspace.network_access_group.application_services",
return_value=SimpleNamespace(network_access_groups=service),
):
yield
@@ -58,7 +69,7 @@ def _group_payload(*, app_ids: list[str] | None = None) -> dict[str, object]:
"allowedCidrs": ["203.0.113.7/32"],
"usedByCount": len(app_ids),
"usedByAppIds": app_ids,
# ProtoJSON represents int64 values as strings and timestamps as RFC 3339.
"apps": [],
"version": "2",
"updatedByAccountId": ACCOUNT_ID,
"createdAt": datetime(2026, 8, 21, tzinfo=UTC).isoformat(),
@@ -86,211 +97,135 @@ def _binding_payload(
}
def test_list_authorizes_and_forwards_tenant_and_actor() -> None:
def test_list_forwards_request_context_and_serializes_response() -> None:
service = MagicMock()
service.list_groups.return_value = {
"tenantId": TENANT_ID,
"entitled": True,
"groups": [_group_payload()],
}
api = CurrentWorkspaceNetworkAccessGroupsApi()
method = unwrap(api.get)
current_user = _current_user()
upstream_payload = {"tenantId": TENANT_ID, "entitled": True, "groups": [_group_payload()]}
with (
patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload) as list_groups,
):
result = method(api, current_tenant_id=TENANT_ID, current_user=current_user)
with _application_services(service):
result = unwrap(api.get)(api, request_context=_request_context())
list_groups.assert_called_once_with(TENANT_ID, ACCOUNT_ID)
service.list_groups.assert_called_once_with(_request_context())
assert result["tenant_id"] == TENANT_ID
assert result["groups"][0]["name"] == "Office network"
assert result["groups"][0]["version"] == 2
assert result["groups"][0]["used_by_count"] == 0
assert result["groups"][0]["app_ids"] == []
assert result["groups"][0]["apps"] == []
assert result["groups"][0]["updated_at"] == "2026-08-21T00:00:00Z"
def test_create_injects_actor_and_returns_created_contract() -> None:
api = CurrentWorkspaceNetworkAccessGroupsApi()
method = unwrap(api.post)
current_user = _current_user()
def test_create_forwards_payload_and_returns_201() -> None:
service = MagicMock()
service.create_group.return_value = {"group": _group_payload()}
request_payload = NetworkAccessGroupCreatePayload(
name="Office network",
description="Reusable office egress addresses",
allowed_cidrs=["203.0.113.7/32"],
)
api = CurrentWorkspaceNetworkAccessGroupsApi()
with (
patch.object(
BillingService,
"create_network_access_group",
return_value={"group": _group_payload()},
) as create_group,
):
body, status = method(
with _application_services(service):
body, status = unwrap(api.post)(
api,
req_data=request_payload,
current_tenant_id=TENANT_ID,
current_user=current_user,
request_context=_request_context(),
)
create_group.assert_called_once_with(
TENANT_ID,
service.create_group.assert_called_once_with(
_request_context(),
name="Office network",
description="Reusable office egress addresses",
allowed_cidrs=["203.0.113.7/32"],
actor_account_id=ACCOUNT_ID,
)
assert status == 201
assert body["group"]["id"] == GROUP_ID
def test_update_group_forwards_path_id_and_expected_version() -> None:
api = CurrentWorkspaceNetworkAccessGroupApi()
method = unwrap(api.put)
current_user = _current_user()
request_payload = NetworkAccessGroupUpdatePayload(
def test_group_detail_update_and_delete_forward_path_and_query_values() -> None:
service = MagicMock()
service.get_group.return_value = {"group": _group_payload()}
service.update_group.return_value = {"group": _group_payload()}
service.delete_group.return_value = {"deleted": True}
update_payload = NetworkAccessGroupUpdatePayload(
name="Office network",
description="Updated",
allowed_cidrs=["203.0.113.0/24"],
expected_version=1,
)
api = CurrentWorkspaceNetworkAccessGroupApi()
with (
patch.object(
BillingService,
"update_network_access_group",
return_value={"group": _group_payload()},
) as update_group,
):
result = method(
with _application_services(service):
get_result = unwrap(api.get)(api, request_context=_request_context(), group_id=UUID(GROUP_ID))
update_result = unwrap(api.put)(
api,
req_data=request_payload,
current_tenant_id=TENANT_ID,
current_user=current_user,
req_data=update_payload,
request_context=_request_context(),
group_id=UUID(GROUP_ID),
)
update_group.assert_called_once_with(
TENANT_ID,
GROUP_ID,
name="Office network",
description="Updated",
allowed_cidrs=["203.0.113.0/24"],
expected_version=1,
actor_account_id=ACCOUNT_ID,
)
assert result["group"]["version"] == 2
def test_delete_group_reads_expected_version_from_query_and_injects_actor() -> None:
api = CurrentWorkspaceNetworkAccessGroupApi()
method = unwrap(api.delete)
current_user = _current_user()
with (
patch.object(
BillingService,
"delete_network_access_group",
return_value={"deleted": True},
) as delete_group,
):
result = method(
delete_result = unwrap(api.delete)(
api,
req_data=NetworkAccessGroupDeleteQuery(expected_version=2),
current_tenant_id=TENANT_ID,
current_user=current_user,
request_context=_request_context(),
group_id=UUID(GROUP_ID),
)
delete_group.assert_called_once_with(
TENANT_ID,
GROUP_ID,
expected_version=2,
actor_account_id=ACCOUNT_ID,
service.get_group.assert_called_once_with(_request_context(), group_id=GROUP_ID)
service.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,
)
assert result == {"deleted": True}
service.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_forwards_tenant_scoped_app_and_supports_unbound_response() -> None:
api = AppNetworkAccessGroupApi()
method = unwrap(api.get)
current_user = _current_user()
app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT)
upstream_payload = {"tenantId": TENANT_ID, "appId": APP_ID, "entitled": True}
with (
patch.object(
BillingService,
"get_app_network_access_group",
return_value=upstream_payload,
) as get_binding,
):
result = method(
api,
current_tenant_id=TENANT_ID,
current_user=current_user,
app_model=app_model,
)
get_binding.assert_called_once_with(TENANT_ID, APP_ID, ACCOUNT_ID)
assert result["binding"] is None
assert result["available_access_points"] == ["webapp", "service_api", "mcp"]
@pytest.mark.parametrize("enabled", [True, False])
def test_app_put_atomically_saves_enabled_group_and_access_points(enabled: bool) -> None:
api = AppNetworkAccessGroupApi()
method = unwrap(api.put)
current_user = _current_user()
app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT)
def test_app_get_and_put_forward_app_id_without_orm_models() -> None:
service = MagicMock()
service.get_app_binding.return_value = {
"tenantId": TENANT_ID,
"appId": APP_ID,
"entitled": True,
"available_access_points": ["webapp", "service_api", "mcp"],
"binding": None,
}
service.update_app_binding.return_value = {
"binding": _binding_payload(),
"available_access_points": ["webapp", "service_api", "mcp"],
}
request_payload = AppNetworkAccessGroupUpdatePayload(
enabled=enabled,
enabled=True,
group_id=GROUP_ID,
access_points=["webapp", "service_api"],
expected_version=2,
)
api = AppNetworkAccessGroupApi()
with (
patch.object(
BillingService,
"update_app_network_access_group",
return_value={"binding": _binding_payload(enabled=enabled)},
) as update_binding,
):
result = method(
with _application_services(service):
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,
current_tenant_id=TENANT_ID,
current_user=current_user,
app_model=app_model,
request_context=_request_context(),
app_id=UUID(APP_ID),
)
update_binding.assert_called_once_with(
TENANT_ID,
APP_ID,
enabled=enabled,
service.get_app_binding.assert_called_once_with(_request_context(), app_id=APP_ID)
service.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,
actor_account_id=ACCOUNT_ID,
)
assert result["binding"]["enabled"] is enabled
assert result["binding"]["group_id"] == GROUP_ID
assert result["binding"]["access_points"] == ["webapp", "service_api"]
assert result["binding"]["version"] == 3
assert result["available_access_points"] == ["webapp", "service_api", "mcp"]
def test_list_rejects_non_privileged_workspace_member_before_upstream_call() -> None:
api = CurrentWorkspaceNetworkAccessGroupsApi()
method = unwrap(api.get)
current_user = _current_user(TenantAccountRole.NORMAL)
with (
patch.object(BillingService, "list_network_access_groups") as list_groups,
pytest.raises(Forbidden),
):
method(api, current_tenant_id=TENANT_ID, current_user=current_user)
list_groups.assert_not_called()
assert get_result["binding"] is None
assert put_result["binding"]["version"] == 3
@pytest.mark.parametrize(
@@ -325,124 +260,88 @@ def test_conflict_error_mapping_preserves_safe_actionable_reason(reason: str, ex
def test_internal_secret_error_is_not_reported_as_tenant_input_failure() -> None:
error = _translate_upstream_error(NetworkAccessGroupUpstreamError(400, "INVALID_SECRET_KEY"))
assert isinstance(error, ServiceUnavailable)
def test_list_maps_invalid_upstream_contract_to_bad_gateway() -> None:
api = CurrentWorkspaceNetworkAccessGroupsApi()
method = unwrap(api.get)
current_user = _current_user()
with (
patch.object(
BillingService,
"list_network_access_groups",
return_value={"tenant_id": TENANT_ID, "entitled": True, "groups": [{"allowed_cidrs": []}]},
),
pytest.raises(BadGateway),
):
method(api, current_tenant_id=TENANT_ID, current_user=current_user)
def test_response_contract_accepts_protojson_omitted_optional_group_fields() -> None:
api = CurrentWorkspaceNetworkAccessGroupsApi()
method = unwrap(api.get)
current_user = _current_user()
group = _group_payload()
group.pop("description")
group.pop("usedByAppIds")
group.pop("usedByCount")
with (
patch.object(
BillingService,
"list_network_access_groups",
return_value={"tenantId": TENANT_ID, "entitled": True, "groups": [group]},
),
):
result = method(api, current_tenant_id=TENANT_ID, current_user=current_user)
assert result["groups"][0]["description"] == ""
assert result["groups"][0]["allowed_cidrs"] == ["203.0.113.7/32"]
assert result["groups"][0]["app_ids"] == []
assert result["groups"][0]["used_by_count"] == 0
def test_binding_response_materializes_protojson_omitted_disabled_defaults() -> None:
api = AppNetworkAccessGroupApi()
method = unwrap(api.put)
current_user = _current_user()
app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT)
request_payload = AppNetworkAccessGroupUpdatePayload(
enabled=False,
group_id=None,
access_points=[],
expected_version=2,
assert isinstance(
_translate_upstream_error(NetworkAccessGroupUpstreamError(400, "INVALID_SECRET_KEY")),
ServiceUnavailable,
)
binding = _binding_payload(enabled=False, group_id=None, access_points=[])
binding.pop("groupId")
binding.pop("enabled")
binding.pop("accessPoints")
@pytest.mark.parametrize(
("service_error", "expected_exception", "expected_message"),
[
(NetworkAccessGroupAccessDeniedError(), Forbidden, "owners and administrators"),
(NetworkAccessGroupEntitlementUnavailableError(), ServiceUnavailable, "temporarily unavailable"),
(NetworkAccessGroupAppNotFoundError(), AppNotFoundError, "not found"),
(NetworkAccessGroupUnsupportedAppModeError("channel"), BadRequest, "channel"),
(NetworkAccessGroupUnsupportedAccessPointsError(["trigger"]), BadRequest, "trigger"),
],
)
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,
],
)
def test_mutation_paid_plan_admission_precedes_payload_validation(mutation: Callable[..., object]) -> None:
app = Flask(__name__)
paid_plan_wrapper = unwrap(mutation, stop=lambda candidate: candidate is not mutation)
with (
patch.object(
BillingService,
"update_app_network_access_group",
return_value={"binding": binding},
app.test_request_context(json={"name": "", "description": "", "allowed_cidrs": []}),
patch(
"controllers.console.wraps.current_account_with_tenant",
return_value=(object(), TENANT_ID),
),
patch(
"controllers.console.wraps.is_cloud_edition_billing_paid_plan",
return_value=False,
) as is_paid_plan,
pytest.raises(HTTPException) as exc_info,
):
result = method(
api,
req_data=request_payload,
current_tenant_id=TENANT_ID,
current_user=current_user,
app_model=app_model,
paid_plan_wrapper(object())
assert exc_info.value.code == 403
is_paid_plan.assert_called_once_with(TENANT_ID)
def test_controller_maps_service_error_before_serialization() -> None:
service = MagicMock()
service.list_groups.side_effect = NetworkAccessGroupAccessDeniedError
with _application_services(service), pytest.raises(Forbidden, match="owners and administrators"):
unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)(
CurrentWorkspaceNetworkAccessGroupsApi(),
request_context=_request_context(),
)
assert result["binding"]["group_id"] is None
assert result["binding"]["enabled"] is False
assert result["binding"]["access_points"] == []
def test_binding_response_accepts_policy_id_alias() -> None:
api = AppNetworkAccessGroupApi()
method = unwrap(api.put)
app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.CHAT)
request_payload = AppNetworkAccessGroupUpdatePayload(
enabled=True,
group_id=GROUP_ID,
access_points=["webapp"],
expected_version=2,
)
binding = _binding_payload(access_points=["webapp"])
binding["policyId"] = binding.pop("groupId")
with patch.object(BillingService, "update_app_network_access_group", return_value={"binding": binding}):
result = method(
api,
req_data=request_payload,
current_tenant_id=TENANT_ID,
current_user=_current_user(),
app_model=app_model,
)
assert result["binding"]["group_id"] == GROUP_ID
def test_binding_response_hides_scopes_not_available_for_current_app_mode() -> None:
payload = {
"binding": _binding_payload(
enabled=True,
group_id=GROUP_ID,
access_points=["webapp", "service_api", "mcp", "trigger"],
)
def test_invalid_service_response_is_bad_gateway() -> None:
service = MagicMock()
service.list_groups.return_value = {
"tenant_id": TENANT_ID,
"entitled": True,
"groups": [{"allowed_cidrs": list[str]()}],
}
_limit_binding_access_points(payload, ["webapp", "service_api"])
assert payload["binding"]["access_points"] == ["webapp", "service_api"]
assert "accessPoints" not in payload["binding"]
with _application_services(service), pytest.raises(BadGateway):
unwrap(CurrentWorkspaceNetworkAccessGroupsApi().get)(
CurrentWorkspaceNetworkAccessGroupsApi(),
request_context=_request_context(),
)
@pytest.mark.parametrize(
@@ -458,67 +357,6 @@ def test_create_payload_contract_rejects_invalid_shapes(payload: dict[str, objec
NetworkAccessGroupCreatePayload.model_validate(payload)
def test_binding_payload_rejects_invalid_group_id_and_version() -> None:
with pytest.raises(ValidationError):
AppNetworkAccessGroupUpdatePayload.model_validate(
{
"enabled": True,
"group_id": "not-a-uuid",
"access_points": ["webapp"],
"expected_version": -1,
}
)
@pytest.mark.parametrize(
("app_mode", "expected"),
[
(AppMode.WORKFLOW, ["webapp", "service_api", "mcp", "trigger"]),
(AppMode.ADVANCED_CHAT, ["webapp", "service_api", "mcp"]),
(AppMode.CHAT, ["webapp", "service_api", "mcp"]),
(AppMode.COMPLETION, ["webapp", "service_api", "mcp"]),
(AppMode.AGENT_CHAT, ["webapp", "service_api", "mcp"]),
(AppMode.AGENT, ["webapp", "service_api"]),
],
)
def test_available_access_points_are_derived_from_app_mode(app_mode: AppMode, expected: list[str]) -> None:
app_model = SimpleNamespace(mode=app_mode)
assert _available_access_points(cast(App, app_model)) == expected
@pytest.mark.parametrize("app_mode", [AppMode.CHANNEL, AppMode.RAG_PIPELINE])
def test_unsupported_app_modes_are_rejected(app_mode: AppMode) -> None:
with pytest.raises(BadRequest, match="not supported"):
_available_access_points(cast(App, SimpleNamespace(mode=app_mode)))
def test_app_put_rejects_access_point_not_available_for_mode_before_upstream_call() -> None:
api = AppNetworkAccessGroupApi()
method = unwrap(api.put)
app_model = SimpleNamespace(id=UUID(APP_ID), tenant_id=TENANT_ID, mode=AppMode.AGENT)
request_payload = AppNetworkAccessGroupUpdatePayload(
enabled=True,
group_id=GROUP_ID,
access_points=["webapp", "mcp"],
expected_version=1,
)
with (
patch.object(BillingService, "update_app_network_access_group") as update_binding,
pytest.raises(BadRequest, match="mcp"),
):
method(
api,
req_data=request_payload,
current_tenant_id=TENANT_ID,
current_user=_current_user(),
app_model=app_model,
)
update_binding.assert_not_called()
@pytest.mark.parametrize(
"payload",
[
@@ -550,83 +388,3 @@ def test_app_config_accepts_policy_id_alias_and_disabled_draft() -> None:
assert str(payload.group_id) == GROUP_ID
assert payload.access_points == ["webapp", "service_api"]
def test_group_responses_are_enriched_with_tenant_scoped_app_metadata() -> None:
app_id_2 = "66666666-6666-4666-8666-666666666666"
payload = {
"group": _group_payload(app_ids=[APP_ID, app_id_2]),
}
payload["group"].pop("usedByCount")
app = SimpleNamespace(
id=APP_ID,
name="Customer support",
icon="robot",
icon_type=SimpleNamespace(value="emoji"),
icon_background="#FFFFFF",
)
db_mock = MagicMock()
db_mock.session.scalars.return_value.all.return_value = [app]
with patch("services.network_access_group_service.db", db_mock):
result = NetworkAccessGroupService.enrich_app_references(payload, TENANT_ID)
assert result["group"]["apps"] == [
{
"id": APP_ID,
"name": "Customer support",
"icon": "robot",
"icon_type": "emoji",
"icon_background": "#FFFFFF",
}
]
assert result["group"]["app_ids"] == [APP_ID, app_id_2]
assert result["group"]["used_by_count"] == 2
db_mock.session.scalars.assert_called_once()
def test_group_app_enrichment_degrades_to_ids_when_core_database_is_unavailable() -> None:
payload = {"group": _group_payload(app_ids=[APP_ID])}
db_mock = MagicMock()
db_mock.session.scalars.side_effect = SQLAlchemyError("database unavailable")
with patch("services.network_access_group_service.db", db_mock):
result = NetworkAccessGroupService.enrich_app_references(payload, TENANT_ID)
assert result["group"]["app_ids"] == [APP_ID]
assert result["group"]["used_by_count"] == 1
assert result["group"]["apps"] == []
def test_sandbox_read_returns_not_entitled_for_upgrade_state() -> None:
api = CurrentWorkspaceNetworkAccessGroupsApi()
method = unwrap(api.get)
upstream_payload: dict[str, object] = {
"tenantId": TENANT_ID,
"entitled": True,
"groups": list[object](),
}
with (
patch.object(BillingService, "list_network_access_groups", return_value=upstream_payload),
patch("controllers.console.workspace.network_access_group._effective_entitlement", return_value=False),
):
result = method(api, current_tenant_id=TENANT_ID, current_user=_current_user())
assert result["entitled"] is False
@pytest.mark.parametrize(
("upstream_entitled", "paid_plan", "expected"),
[(True, True, True), (False, True, False), (True, False, False)],
)
def test_effective_entitlement_requires_rollout_and_paid_plan(
upstream_entitled: bool,
paid_plan: bool,
expected: bool,
) -> None:
with patch(
"controllers.console.workspace.network_access_group.is_cloud_edition_billing_paid_plan",
return_value=paid_plan,
):
assert _effective_entitlement(TENANT_ID, upstream_entitled) is expected
@@ -34,6 +34,7 @@ from repositories.app_statistic_query_repository import AppStatisticQueryReposit
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository
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
@@ -76,6 +77,11 @@ from services.file_service import FileService
from services.human_input_file_upload_service import HumanInputFileUploadService
from services.init_validation_service import InvalidInitializationPasswordError
from services.message_file_preview_service import MessageFilePreviewService
from services.network_access_group_gateway import (
BillingNetworkAccessGroupEntitlementGateway,
NetworkAccessGroupGateway,
)
from services.network_access_group_service import NetworkAccessGroupService
from services.partner_tenant_binding_service import PartnerTenantBindingService
from services.plugin_file_upload_gateway import ToolFilePluginUploadGateway
from services.plugin_file_upload_service import PluginFileUploadService
@@ -479,6 +485,24 @@ def test_build_application_services_wires_billing_service(
sync_partner_tenants_bindings.assert_called_once_with("account-1", "partner-key", "click-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=MagicMock(spec=RedisClientWrapper),
)
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)
def test_build_application_services_wires_compliance_downloads(
sqlite_session_factory: sessionmaker[Session],
) -> None:
@@ -0,0 +1,169 @@
from unittest.mock import MagicMock
import pytest
from sqlalchemy import 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
def test_list_apps_maps_database_failures_to_query_error() -> None:
session_factory = MagicMock()
session = session_factory.return_value.__enter__.return_value
session.scalars.side_effect = SQLAlchemyError("database unavailable")
with pytest.raises(NetworkAccessGroupAppQueryError):
_repository(session_factory).list_apps(workspace_id=_WORKSPACE_ID, app_ids=[_APP_ID])
@@ -0,0 +1,58 @@
from sqlalchemy.orm import Session, sessionmaker
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from repositories.workspace_query_repository import WorkspaceQueryRepository
_WORKSPACE_ID = "11111111-1111-1111-1111-111111111111"
_OTHER_WORKSPACE_ID = "22222222-2222-2222-2222-222222222222"
_OWNER_ID = "33333333-3333-3333-3333-333333333333"
_ADMIN_ID = "44444444-4444-4444-4444-444444444444"
_OTHER_WORKSPACE_ACCOUNT_ID = "55555555-5555-5555-5555-555555555555"
def _account(account_id: str) -> Account:
account = Account(name=f"Account {account_id}", email=f"{account_id}@example.com")
account.id = account_id
return account
def test_get_role_for_account_is_scoped_by_workspace_and_account(
sqlite_session_factory: sessionmaker[Session],
) -> None:
workspace = Tenant(name="Workspace")
workspace.id = _WORKSPACE_ID
other_workspace = Tenant(name="Other workspace")
other_workspace.id = _OTHER_WORKSPACE_ID
with sqlite_session_factory() as session:
session.add_all(
[
workspace,
other_workspace,
_account(_OWNER_ID),
_account(_ADMIN_ID),
_account(_OTHER_WORKSPACE_ACCOUNT_ID),
TenantAccountJoin(
tenant_id=_WORKSPACE_ID,
account_id=_OWNER_ID,
role=TenantAccountRole.OWNER,
),
TenantAccountJoin(
tenant_id=_WORKSPACE_ID,
account_id=_ADMIN_ID,
role=TenantAccountRole.ADMIN,
),
TenantAccountJoin(
tenant_id=_OTHER_WORKSPACE_ID,
account_id=_OTHER_WORKSPACE_ACCOUNT_ID,
role=TenantAccountRole.OWNER,
),
]
)
session.commit()
repository = WorkspaceQueryRepository(sqlite_session_factory)
assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id=_OWNER_ID) == "owner"
assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id=_ADMIN_ID) == "admin"
assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id="missing-account") is None
assert repository.get_role_for_account(workspace_id=_WORKSPACE_ID, account_id=_OTHER_WORKSPACE_ACCOUNT_ID) is None
@@ -1,10 +1,18 @@
from collections.abc import Callable, Iterator
from unittest.mock import MagicMock, call, patch
import httpx
import pytest
from services.billing_service import BillingService, NetworkAccessGroupUpstreamError
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,
NetworkAccessGroupUpstreamError,
)
TENANT_ID = "11111111-1111-4111-8111-111111111111"
ACCOUNT_ID = "22222222-2222-4222-8222-222222222222"
@@ -13,30 +21,30 @@ GROUP_ID = "44444444-4444-4444-8444-444444444444"
HEADERS = {"Content-Type": "application/json", "Billing-Api-Secret-Key": "test-secret"}
@pytest.fixture
def billing_config(config_overrides: Callable[..., None]) -> Iterator[None]:
config_overrides(NETWORK_ACCESS_API_URL="")
with (
patch.object(BillingService, "base_url", "https://billing.internal/v1"),
patch.object(BillingService, "secret_key", "test-secret"),
):
yield
def _gateway(*, base_url: str = "https://network-access.internal/v1/") -> tuple[NetworkAccessGroupGateway, MagicMock]:
http_client = MagicMock(spec=httpx.Client)
return (
NetworkAccessGroupGateway(
base_url=base_url,
fallback_base_url="https://billing.internal/v1",
secret_key="test-secret",
http_client=http_client,
),
http_client,
)
@pytest.mark.usefixtures("billing_config")
def test_network_access_requests_use_independent_api_url_when_configured(
config_overrides: Callable[..., None],
) -> None:
def test_network_access_requests_use_independent_api_url_when_configured() -> None:
gateway, http_client = _gateway()
response = MagicMock(status_code=httpx.codes.OK)
response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()}
http_client.request.return_value = response
config_overrides(NETWORK_ACCESS_API_URL="https://saas.internal/v1/")
with patch("services.billing_service._http_client.request", return_value=response) as request:
BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID)
gateway.list_groups(TENANT_ID, ACCOUNT_ID)
request.assert_called_once_with(
http_client.request.assert_called_once_with(
"GET",
f"https://saas.internal/v1/tenants/{TENANT_ID}/network-access-groups",
f"https://network-access.internal/v1/tenants/{TENANT_ID}/network-access-groups",
json=None,
params={"actor_account_id": ACCOUNT_ID},
headers=HEADERS,
@@ -44,15 +52,15 @@ def test_network_access_requests_use_independent_api_url_when_configured(
)
@pytest.mark.usefixtures("billing_config")
def test_network_access_requests_fall_back_to_billing_api_url_at_request_time() -> None:
def test_network_access_requests_fall_back_to_billing_api_url() -> None:
gateway, http_client = _gateway(base_url="")
response = MagicMock(status_code=httpx.codes.OK)
response.json.return_value = {"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()}
http_client.request.return_value = response
with patch("services.billing_service._http_client.request", return_value=response) as request:
BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID)
gateway.list_groups(TENANT_ID, ACCOUNT_ID)
request.assert_called_once_with(
http_client.request.assert_called_once_with(
"GET",
f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups",
json=None,
@@ -62,40 +70,21 @@ def test_network_access_requests_fall_back_to_billing_api_url_at_request_time()
)
@pytest.mark.usefixtures("billing_config")
def test_subscription_requests_keep_using_billing_api_url(config_overrides: Callable[..., None]) -> None:
response = MagicMock(status_code=httpx.codes.OK)
response.json.return_value = {"subscription": {"plan": "professional"}}
config_overrides(NETWORK_ACCESS_API_URL="https://saas.internal/v1")
with patch("services.billing_service._http_client.request", return_value=response) as request:
BillingService._send_request("GET", "/subscription/info", params={"tenant_id": TENANT_ID})
request.assert_called_once_with(
"GET",
"https://billing.internal/v1/subscription/info",
json=None,
params={"tenant_id": TENANT_ID},
headers=HEADERS,
follow_redirects=True,
)
@pytest.mark.usefixtures("billing_config")
def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None:
gateway, http_client = _gateway(base_url="")
response = MagicMock(status_code=httpx.codes.OK)
response.json.side_effect = [
{"tenant_id": TENANT_ID, "entitled": True, "groups": list[object]()},
{"group": {"id": GROUP_ID}},
]
http_client.request.return_value = response
with patch("services.billing_service._http_client.request", return_value=response) as request:
list_result = BillingService.list_network_access_groups(TENANT_ID, ACCOUNT_ID)
item_result = BillingService.get_network_access_group(TENANT_ID, GROUP_ID, ACCOUNT_ID)
list_result = gateway.list_groups(TENANT_ID, ACCOUNT_ID)
item_result = gateway.get_group(TENANT_ID, GROUP_ID, ACCOUNT_ID)
assert list_result["groups"] == []
assert item_result["group"]["id"] == GROUP_ID
assert request.call_args_list == [
assert http_client.request.call_args_list == [
call(
"GET",
f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups",
@@ -115,40 +104,40 @@ def test_group_list_and_item_reads_use_authenticated_saas_endpoints() -> None:
]
@pytest.mark.usefixtures("billing_config")
def test_group_mutations_inject_actor_and_use_expected_version() -> None:
gateway, http_client = _gateway(base_url="")
response = MagicMock(status_code=httpx.codes.OK)
response.json.side_effect = [
{"group": {"id": GROUP_ID, "version": "1"}},
{"group": {"id": GROUP_ID, "version": "2"}},
{"deleted": True},
]
http_client.request.return_value = response
with patch("services.billing_service._http_client.request", return_value=response) as request:
BillingService.create_network_access_group(
TENANT_ID,
name="Office",
description="Office egress",
allowed_cidrs=["203.0.113.7/32"],
actor_account_id=ACCOUNT_ID,
)
BillingService.update_network_access_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,
)
BillingService.delete_network_access_group(
TENANT_ID,
GROUP_ID,
expected_version=2,
actor_account_id=ACCOUNT_ID,
)
gateway.create_group(
TENANT_ID,
name="Office",
description="Office egress",
allowed_cidrs=["203.0.113.7/32"],
actor_account_id=ACCOUNT_ID,
)
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,
)
gateway.delete_group(
TENANT_ID,
GROUP_ID,
expected_version=2,
actor_account_id=ACCOUNT_ID,
)
assert request.call_args_list == [
assert http_client.request.call_args_list == [
call(
"POST",
f"https://billing.internal/v1/tenants/{TENANT_ID}/network-access-groups",
@@ -187,8 +176,8 @@ def test_group_mutations_inject_actor_and_use_expected_version() -> None:
]
@pytest.mark.usefixtures("billing_config")
def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None:
gateway, http_client = _gateway(base_url="")
response = MagicMock(status_code=httpx.codes.OK)
response.json.side_effect = [
{"tenant_id": TENANT_ID, "app_id": APP_ID, "entitled": True, "binding": None},
@@ -211,30 +200,30 @@ def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None:
}
},
]
http_client.request.return_value = response
with patch("services.billing_service._http_client.request", return_value=response) as request:
BillingService.get_app_network_access_group(TENANT_ID, APP_ID, ACCOUNT_ID)
BillingService.update_app_network_access_group(
TENANT_ID,
APP_ID,
enabled=True,
group_id=GROUP_ID,
access_points=["webapp", "service_api"],
expected_version=0,
actor_account_id=ACCOUNT_ID,
)
BillingService.update_app_network_access_group(
TENANT_ID,
APP_ID,
enabled=False,
group_id=GROUP_ID,
access_points=["webapp", "service_api"],
expected_version=1,
actor_account_id=ACCOUNT_ID,
)
gateway.get_app_binding(TENANT_ID, APP_ID, ACCOUNT_ID)
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,
)
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 request.call_args_list == [
assert http_client.request.call_args_list == [
call(
"GET",
endpoint,
@@ -274,16 +263,16 @@ def test_app_config_read_and_atomic_write_use_tenant_and_app_path() -> None:
]
@pytest.mark.usefixtures("billing_config")
def test_app_lifecycle_cleanup_uses_internal_secret_endpoint() -> None:
gateway, http_client = _gateway(base_url="")
response = MagicMock(status_code=httpx.codes.OK)
response.json.return_value = {"deleted": True}
http_client.request.return_value = response
with patch("services.billing_service._http_client.request", return_value=response) as request:
result = BillingService.cleanup_app_network_access_group_binding(TENANT_ID, APP_ID)
result = gateway.cleanup_app_binding(TENANT_ID, APP_ID)
assert result == {"deleted": True}
request.assert_called_once_with(
http_client.request.assert_called_once_with(
"DELETE",
f"https://billing.internal/v1/tenants/{TENANT_ID}/apps/{APP_ID}/network-access-group-binding",
json=None,
@@ -295,51 +284,112 @@ def test_app_lifecycle_cleanup_uses_internal_secret_endpoint() -> None:
@pytest.mark.parametrize("status_code", [400, 403, 404, 409, 500, 503])
def test_network_access_group_request_preserves_upstream_status(status_code: int) -> None:
gateway, _ = _gateway()
response = MagicMock(status_code=status_code)
with (
patch.object(BillingService, "_send_network_access_group_http_request", return_value=response),
patch.object(gateway, "_send_http_request", return_value=response),
pytest.raises(NetworkAccessGroupUpstreamError) as exc_info,
):
BillingService._send_network_access_group_request("GET", "/groups")
gateway._send_request("GET", "/groups")
assert exc_info.value.status_code == status_code
def test_network_access_group_request_preserves_whitelisted_upstream_reason() -> None:
def test_network_access_group_request_preserves_upstream_reason() -> None:
gateway, _ = _gateway()
response = MagicMock(status_code=httpx.codes.CONFLICT)
response.json.return_value = {"code": 409, "reason": "NETWORK_ACCESS_VERSION_CONFLICT", "message": "internal"}
with (
patch.object(BillingService, "_send_network_access_group_http_request", return_value=response),
patch.object(gateway, "_send_http_request", return_value=response),
pytest.raises(NetworkAccessGroupUpstreamError) as exc_info,
):
BillingService._send_network_access_group_request("DELETE", "/groups/id")
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() -> None:
request = httpx.Request("GET", "https://billing.internal/v1/groups")
gateway, _ = _gateway()
request = httpx.Request("GET", "https://network-access.internal/v1/groups")
with (
patch.object(
BillingService,
"_send_network_access_group_http_request",
gateway,
"_send_http_request",
side_effect=httpx.ConnectError("unavailable", request=request),
),
pytest.raises(NetworkAccessGroupUpstreamError) as exc_info,
):
BillingService._send_network_access_group_request("GET", "/groups")
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() -> None:
gateway, _ = _gateway()
response = MagicMock(status_code=httpx.codes.OK)
response.json.return_value = list[object]()
with (
patch.object(BillingService, "_send_network_access_group_http_request", return_value=response),
patch.object(gateway, "_send_http_request", return_value=response),
pytest.raises(NetworkAccessGroupUpstreamError) as exc_info,
):
BillingService._send_network_access_group_request("GET", "/groups")
gateway._send_request("GET", "/groups")
assert exc_info.value.status_code == httpx.codes.BAD_GATEWAY
@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)
@@ -0,0 +1,444 @@
from collections.abc import Callable
from typing import NamedTuple
from unittest.mock import MagicMock, create_autospec
import pytest
from machinery.context import RequestContext
from services.network_access_group_service import (
NetworkAccessGroupAccessDeniedError,
NetworkAccessGroupAppNotFoundError,
NetworkAccessGroupAppQuery,
NetworkAccessGroupAppQueryError,
NetworkAccessGroupAppRecord,
NetworkAccessGroupControlPlane,
NetworkAccessGroupEntitlement,
NetworkAccessGroupService,
NetworkAccessGroupUnsupportedAccessPointsError,
NetworkAccessGroupUnsupportedAppModeError,
WorkspaceMembershipRoleQuery,
)
WORKSPACE_ID = "workspace-1"
ACCOUNT_ID = "account-1"
APP_ID = "app-1"
GROUP_ID = "group-1"
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") -> NetworkAccessGroupAppRecord:
return NetworkAccessGroupAppRecord(
id=app_id,
mode=mode,
name=name,
icon=f"{app_id}.png",
icon_type="image",
icon_background="#FFFFFF",
)
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_role_for_account.return_value = role
entitlement.is_paid_plan.return_value = paid
apps.get_manageable_app.return_value = _app(mode=app_mode)
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", None])
def test_reads_reject_non_privileged_persisted_roles_before_control_plane(role: str | None) -> None:
harness = _harness(role=role)
with pytest.raises(NetworkAccessGroupAccessDeniedError):
harness.service.list_groups(_context())
harness.memberships.get_role_for_account.assert_called_once_with(
workspace_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", 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_role_for_account.assert_called_once_with(
workspace_id=WORKSPACE_ID,
account_id=ACCOUNT_ID,
)
assert harness.control_plane.method_calls == []
@pytest.mark.parametrize("role", ["owner", "admin"])
def test_reads_allow_persisted_owner_and_admin(role: str) -> None:
harness = _harness(role=role)
harness.control_plane.list_groups.return_value = {"entitled": True, "groups": list[object]()}
harness.service.list_groups(_context())
harness.control_plane.list_groups.assert_called_once_with(WORKSPACE_ID, ACCOUNT_ID)
def test_mutation_does_not_repeat_http_paid_plan_admission() -> None:
harness = _harness()
harness.control_plane.create_group.return_value = {"group": {"id": GROUP_ID}}
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()
def test_public_use_cases_forward_workspace_actor_and_request_parameters() -> None:
harness = _harness()
harness.control_plane.list_groups.return_value = {"entitled": True, "groups": list[object]()}
harness.control_plane.create_group.return_value = {"group": {"id": GROUP_ID}}
harness.control_plane.get_group.return_value = {"group": {"id": GROUP_ID}}
harness.control_plane.update_group.return_value = {"group": {"id": GROUP_ID}}
harness.control_plane.delete_group.return_value = {"deleted": True}
harness.control_plane.get_app_binding.return_value = {"entitled": True, "binding": None}
harness.control_plane.update_app_binding.return_value = dict[str, object](binding=None)
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 = {"deleted": True}
result = harness.service.cleanup_app_binding(workspace_id=WORKSPACE_ID, app_id=APP_ID)
assert result == {"deleted": True}
harness.control_plane.cleanup_app_binding.assert_called_once_with(WORKSPACE_ID, APP_ID)
harness.memberships.get_role_for_account.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 = {
"entitled": upstream_entitled,
"groups": list[object](),
}
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 = {"entitled": True, "binding": None}
result = harness.service.get_app_binding(_context(), app_id=APP_ID)
assert result["available_access_points"] == 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_role_for_account.assert_not_called()
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_role_for_account.assert_not_called()
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_app_binding_materializes_protojson_defaults() -> None:
harness = _harness(app_mode="chat")
harness.control_plane.get_app_binding.return_value = {
"entitled": True,
"binding": dict[str, object](),
}
result = harness.service.get_app_binding(_context(), app_id=APP_ID)
assert result["binding"] == {"enabled": False, "access_points": []}
def test_app_binding_normalizes_camel_case_and_filters_stale_access_points() -> None:
harness = _harness(app_mode="chat")
harness.control_plane.get_app_binding.return_value = {
"entitled": True,
"binding": {"enabled": True, "accessPoints": ["webapp", "trigger", "mcp"]},
}
result = harness.service.get_app_binding(_context(), app_id=APP_ID)
assert result["binding"] == {"enabled": True, "access_points": ["webapp", "mcp"]}
def test_group_payloads_are_enriched_with_tenant_scoped_app_metadata() -> None:
harness = _harness()
harness.control_plane.list_groups.return_value = {
"entitled": True,
"groups": [
{
"id": "group-1",
"usedByAppIds": ["app-2", "missing-app", "app-1"],
"usedByCount": "3",
},
{"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"] == [
{
"id": "app-1",
"name": "One",
"icon": "app-1.png",
"icon_type": "image",
"icon_background": "#FFFFFF",
}
]
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": {"id": GROUP_ID, "used_by_app_ids": [APP_ID], "used_by_count": 1}
}
harness.apps.list_apps.side_effect = NetworkAccessGroupAppQueryError
result = harness.service.get_group(_context(), group_id=GROUP_ID)
assert result["group"]["app_ids"] == [APP_ID]
assert result["group"]["apps"] == []
@@ -1,5 +1,6 @@
import logging
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
from uuid import uuid4
@@ -14,7 +15,7 @@ from models import AppStar
from models.agent import WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.workflow import WorkflowArchiveLog
from services.billing_service import NetworkAccessGroupUpstreamError
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,
@@ -152,17 +153,20 @@ def test_community_app_cleanup_does_not_schedule_binding_cleanup(monkeypatch: py
def test_independent_binding_cleanup_task_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
cleanup = MagicMock(return_value={"deleted": True})
service = MagicMock()
service.cleanup_app_binding.return_value = {"deleted": True}
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
monkeypatch.setattr(
remove_app_task_module.BillingService,
"cleanup_app_network_access_group_binding",
cleanup,
)
remove_app_task_module.cleanup_app_network_access_group_binding_task.run(tenant_id="tenant-1", app_id="app-1")
with patch(
"extensions.ext_application_services.application_services",
return_value=SimpleNamespace(network_access_groups=service),
):
remove_app_task_module.cleanup_app_network_access_group_binding_task.run(
tenant_id="tenant-1",
app_id="app-1",
)
cleanup.assert_called_once_with("tenant-1", "app-1")
service.cleanup_app_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
@@ -172,16 +176,22 @@ def test_independent_binding_cleanup_task_retries_with_exponential_backoff(
upstream_error = NetworkAccessGroupUpstreamError(409, "NETWORK_ACCESS_APP_STILL_EXISTS")
retry_error = RuntimeError("retry scheduled")
retry = MagicMock(side_effect=retry_error)
service = MagicMock()
service.cleanup_app_binding.side_effect = upstream_error
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
monkeypatch.setattr(
remove_app_task_module.BillingService,
"cleanup_app_network_access_group_binding",
MagicMock(side_effect=upstream_error),
)
monkeypatch.setattr(remove_app_task_module.cleanup_app_network_access_group_binding_task, "retry", retry)
with 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")
with (
patch(
"extensions.ext_application_services.application_services",
return_value=SimpleNamespace(network_access_groups=service),
),
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