diff --git a/frontend/service/studio_release_server/deploy.py b/frontend/service/studio_release_server/deploy.py index 47d93a2d9..8224af6c9 100644 --- a/frontend/service/studio_release_server/deploy.py +++ b/frontend/service/studio_release_server/deploy.py @@ -449,6 +449,71 @@ def _public_artifact_policy(bucket: str) -> dict[str, object]: } +def _normalized_policy_statement(statement: dict[str, object]) -> dict[str, object]: + """Normalize TOS scalar/list response variants for semantic comparison.""" + + normalized = dict(statement) + for field in ("Principal", "Action", "Resource"): + value = normalized.get(field) + values = value if isinstance(value, list) else [value] + normalized[field] = sorted( + values, + key=lambda item: json.dumps( + item, + sort_keys=True, + separators=(",", ":"), + ), + ) + return normalized + + +def _policy_statement_matches( + actual: dict[str, object], + expected: dict[str, object], +) -> bool: + return _normalized_policy_statement(actual) == _normalized_policy_statement( + expected + ) + + +def _policy_matches(actual: object, expected: object) -> bool: + """Compare complete policies while accepting only observed TOS normalization.""" + + if not isinstance(actual, dict) or not isinstance(expected, dict): + return False + actual_metadata = { + key: value for key, value in actual.items() if key != "Statement" + } + expected_metadata = { + key: value for key, value in expected.items() if key != "Statement" + } + if "Version" not in expected_metadata and actual_metadata.get("Version") == "1.0": + actual_metadata.pop("Version") + if actual_metadata != expected_metadata: + return False + + def normalized_statements(policy: dict[str, object]) -> list[dict[str, object]]: + raw = policy.get("Statement", []) + values = [raw] if isinstance(raw, dict) else raw + if not isinstance(values, list) or not all( + isinstance(item, dict) for item in values + ): + return [] + statements = [_normalized_policy_statement(item) for item in values] + return sorted( + statements, + key=lambda item: json.dumps( + item, + sort_keys=True, + separators=(",", ":"), + ), + ) + + actual_statements = normalized_statements(actual) + expected_statements = normalized_statements(expected) + return bool(actual_statements) and actual_statements == expected_statements + + def _is_tos_not_found(error: Exception) -> bool: return isinstance(error, KeyError) or getattr(error, "status_code", None) == 404 @@ -486,7 +551,7 @@ def _merge_public_artifact_policy( for statement in statements if statement.get("Sid") == _PUBLIC_ARTIFACT_POLICY_SID ] - if len(owned) > 1 or (owned and owned[0] != expected): + if len(owned) > 1 or (owned and not _policy_statement_matches(owned[0], expected)): raise RuntimeError("Studio public artifact bucket policy has a conflict.") if owned: policy["Statement"] = statements @@ -545,8 +610,8 @@ def _ensure_public_artifact_bucket(client: Any, provider: CloudProvider) -> str: raise RuntimeError( "Studio public artifact bucket policy verification failed." ) from error - verified, _ = _merge_public_artifact_policy(actual, bucket) - if verified != expected: + verified, verification_changed = _merge_public_artifact_policy(actual, bucket) + if verification_changed or not _policy_matches(verified, expected): raise RuntimeError("Studio public artifact bucket policy verification failed.") return bucket diff --git a/tests/cli/test_frontend_deploy_iam_vestack.py b/tests/cli/test_frontend_deploy_iam_vestack.py index eaab9227f..4dcc8dfdf 100644 --- a/tests/cli/test_frontend_deploy_iam_vestack.py +++ b/tests/cli/test_frontend_deploy_iam_vestack.py @@ -2,6 +2,15 @@ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. import importlib import json diff --git a/tests/cli/test_vestack_studio_deploy.py b/tests/cli/test_vestack_studio_deploy.py index 93f0d7340..a476e03bc 100644 --- a/tests/cli/test_vestack_studio_deploy.py +++ b/tests/cli/test_vestack_studio_deploy.py @@ -1,3 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from types import SimpleNamespace from unittest.mock import MagicMock diff --git a/tests/cli/test_vestack_studio_image.py b/tests/cli/test_vestack_studio_image.py index 3032f2d50..02813b3fa 100644 --- a/tests/cli/test_vestack_studio_image.py +++ b/tests/cli/test_vestack_studio_image.py @@ -1,3 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from pathlib import Path from veadk.utils.cloud_provider import ( diff --git a/tests/frontend/server/test_agentkit_clients.py b/tests/frontend/server/test_agentkit_clients.py index c4835ea7c..49abc0f4f 100644 --- a/tests/frontend/server/test_agentkit_clients.py +++ b/tests/frontend/server/test_agentkit_clients.py @@ -1,3 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from agentkit.sdk.tools.client import AgentkitToolsClient from frontend.server.agentkit_clients import create_agentkit_client diff --git a/tests/integrations/test_ve_apig_vestack.py b/tests/integrations/test_ve_apig_vestack.py index 00541f813..b1f4fed71 100644 --- a/tests/integrations/test_ve_apig_vestack.py +++ b/tests/integrations/test_ve_apig_vestack.py @@ -1,3 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import json from types import SimpleNamespace from unittest.mock import MagicMock diff --git a/tests/integrations/test_ve_faas_image.py b/tests/integrations/test_ve_faas_image.py index ced3b933c..c7777320b 100644 --- a/tests/integrations/test_ve_faas_image.py +++ b/tests/integrations/test_ve_faas_image.py @@ -5,6 +5,12 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from types import SimpleNamespace from unittest.mock import MagicMock diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py index 46da95707..96dd6b767 100644 --- a/tests/test_studio_release_server.py +++ b/tests/test_studio_release_server.py @@ -2176,6 +2176,135 @@ def put_bucket_policy(self, **kwargs: Any) -> None: ] +def test_public_artifact_bucket_accepts_tos_normalized_policy() -> None: + bucket = "veadk-studio-public" + + class _NotFoundError(Exception): + status_code = 404 + + class _Client: + def __init__(self) -> None: + self.policy: dict[str, object] | None = None + self.policy_puts = 0 + + def list_buckets(self) -> Any: + return SimpleNamespace(buckets=[SimpleNamespace(name=bucket)]) + + def get_bucket_tagging(self, **_kwargs: Any) -> Any: + return SimpleNamespace(tag_set=[SimpleNamespace(key="note", value="勿删")]) + + def get_bucket_policy(self, **_kwargs: Any) -> Any: + if self.policy is None: + raise _NotFoundError + statements = self.policy.get("Statement") + assert isinstance(statements, list) and len(statements) == 1 + statement = statements[0] + assert isinstance(statement, dict) + actions = statement.get("Action") + resources = statement.get("Resource") + assert isinstance(actions, list) and len(actions) == 1 + assert isinstance(resources, list) and len(resources) == 1 + normalized = { + "Version": "1.0", + "Statement": { + **statement, + "Principal": [statement["Principal"]], + "Action": actions[0], + "Resource": resources[0], + }, + } + return SimpleNamespace(policy=json.dumps(normalized)) + + def put_bucket_policy(self, **kwargs: Any) -> None: + self.policy = json.loads(kwargs["policy"]) + self.policy_puts += 1 + + client = _Client() + + assert release_deploy._ensure_public_artifact_bucket(client, "volcengine") == bucket + assert release_deploy._ensure_public_artifact_bucket(client, "volcengine") == bucket + assert client.policy_puts == 1 + + +def test_public_artifact_bucket_accepts_normalized_existing_owned_statement() -> None: + bucket = "veadk-studio-public" + + class _Client: + def list_buckets(self) -> Any: + return SimpleNamespace(buckets=[SimpleNamespace(name=bucket)]) + + def get_bucket_tagging(self, **_kwargs: Any) -> Any: + return SimpleNamespace(tag_set=[SimpleNamespace(key="note", value="勿删")]) + + def get_bucket_policy(self, **_kwargs: Any) -> Any: + return SimpleNamespace( + policy=json.dumps( + { + "Version": "1.0", + "Statement": { + "Sid": "PublicReadStudioRuntimeArtifacts", + "Effect": "Allow", + "Principal": ["*"], + "Action": "tos:GetObject", + "Resource": ( + f"trn:tos:::{bucket}/veadk/studio/artifacts/v1/*" + ), + }, + } + ) + ) + + def put_bucket_policy(self, **_kwargs: Any) -> None: + raise AssertionError("normalized matching policy must not be rewritten") + + assert ( + release_deploy._ensure_public_artifact_bucket(_Client(), "volcengine") == bucket + ) + + +def test_public_artifact_bucket_rejects_dropped_unrelated_policy() -> None: + bucket = "veadk-studio-public" + unrelated = { + "Sid": "ExistingPrivateAutomation", + "Effect": "Allow", + "Principal": {"Service": "internal"}, + "Action": ["tos:PutObject"], + "Resource": [f"trn:tos:::{bucket}/internal/*"], + } + + class _Client: + def __init__(self) -> None: + self.policy_puts = 0 + + def list_buckets(self) -> Any: + return SimpleNamespace(buckets=[SimpleNamespace(name=bucket)]) + + def get_bucket_tagging(self, **_kwargs: Any) -> Any: + return SimpleNamespace(tag_set=[SimpleNamespace(key="note", value="勿删")]) + + def get_bucket_policy(self, **_kwargs: Any) -> Any: + statements = [unrelated] + if self.policy_puts: + statements = [ + { + "Sid": "PublicReadStudioRuntimeArtifacts", + "Effect": "Allow", + "Principal": ["*"], + "Action": "tos:GetObject", + "Resource": (f"trn:tos:::{bucket}/veadk/studio/artifacts/v1/*"), + } + ] + return SimpleNamespace( + policy=json.dumps({"Version": "2012-10-17", "Statement": statements}) + ) + + def put_bucket_policy(self, **_kwargs: Any) -> None: + self.policy_puts += 1 + + with pytest.raises(RuntimeError, match="policy verification failed"): + release_deploy._ensure_public_artifact_bucket(_Client(), "volcengine") + + def test_public_artifact_bucket_rejects_owned_policy_conflict() -> None: with pytest.raises(RuntimeError, match="policy has a conflict"): release_deploy._merge_public_artifact_policy( diff --git a/veadk/cli/frontend_deploy_iam_vestack.py b/veadk/cli/frontend_deploy_iam_vestack.py index 40c0295c1..dddbd68d8 100644 --- a/veadk/cli/frontend_deploy_iam_vestack.py +++ b/veadk/cli/frontend_deploy_iam_vestack.py @@ -2,6 +2,15 @@ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """VeStack IAM role provisioning for a VeFaaS-hosted Studio.""" diff --git a/veadk/cli/frontend_sandbox_managed_tool_vestack.py b/veadk/cli/frontend_sandbox_managed_tool_vestack.py index 3ef5b9454..98d911389 100644 --- a/veadk/cli/frontend_sandbox_managed_tool_vestack.py +++ b/veadk/cli/frontend_sandbox_managed_tool_vestack.py @@ -2,6 +2,15 @@ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """VeStack per-agent Tool provisioning for Studio Sandbox sessions.""" diff --git a/veadk/cli/vestack_studio_deploy.py b/veadk/cli/vestack_studio_deploy.py index 0516e6104..46a70e1a3 100644 --- a/veadk/cli/vestack_studio_deploy.py +++ b/veadk/cli/vestack_studio_deploy.py @@ -2,6 +2,15 @@ # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. """Deploy Studio as a VeStack VeFaaS image function behind APIG."""