Skip to content

Commit 5aaef48

Browse files
committed
feat: add ResourcePolicyClient for resource-based policy management
1 parent 62fdc9a commit 5aaef48

5 files changed

Lines changed: 272 additions & 0 deletions

File tree

.github/workflows/integration-testing.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ jobs:
128128
timeout: 15
129129
extra-deps: "strands-agents-evals"
130130
ignore: ""
131+
- group: services
132+
path: tests_integ/services
133+
timeout: 5
134+
extra-deps: ""
135+
ignore: ""
131136
steps:
132137
- name: Configure Credentials
133138
uses: aws-actions/configure-aws-credentials@v5
@@ -160,6 +165,8 @@ jobs:
160165
MEMORY_KINESIS_ARN: ${{ secrets.MEMORY_KINESIS_ARN }}
161166
MEMORY_ROLE_ARN: ${{ secrets.MEMORY_ROLE_ARN }}
162167
MEMORY_PREPOPULATED_ID: ${{ secrets.MEMORY_PREPOPULATED_ID }}
168+
RESOURCE_POLICY_TEST_ARN: ${{ secrets.RESOURCE_POLICY_TEST_ARN }}
169+
RESOURCE_POLICY_TEST_PRINCIPAL: ${{ secrets.RESOURCE_POLICY_TEST_PRINCIPAL }}
163170
id: tests
164171
timeout-minutes: ${{ matrix.timeout }}
165172
run: |
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Client for managing resource-based policies on Bedrock AgentCore resources."""
2+
3+
import json
4+
import logging
5+
from typing import Optional, Union
6+
7+
import boto3
8+
9+
from bedrock_agentcore._utils.endpoints import get_control_plane_endpoint
10+
11+
12+
class ResourcePolicyClient:
13+
"""Client for managing resource-based policies on Bedrock AgentCore resources.
14+
15+
Resource-based policies control which principals can invoke and manage
16+
Agent Runtime, Endpoint, and Gateway resources.
17+
"""
18+
19+
def __init__(self, region: str):
20+
"""Initialize the client for the specified region."""
21+
self.region = region
22+
self.client = boto3.client(
23+
"bedrock-agentcore-control",
24+
region_name=region,
25+
endpoint_url=get_control_plane_endpoint(region),
26+
)
27+
self.logger = logging.getLogger("bedrock_agentcore.resource_policy_client")
28+
29+
def put_resource_policy(self, resource_arn: str, policy: Union[str, dict]) -> dict:
30+
"""Create or update a resource-based policy.
31+
32+
Args:
33+
resource_arn: ARN of the resource to attach the policy to.
34+
policy: Policy document as a dict (auto-serialized) or JSON string.
35+
36+
Returns:
37+
The stored policy as a dict.
38+
"""
39+
policy_str = json.dumps(policy) if isinstance(policy, dict) else policy
40+
self.logger.info("Putting resource policy for %s", resource_arn)
41+
resp = self.client.put_resource_policy(resourceArn=resource_arn, policy=policy_str)
42+
return json.loads(resp["policy"])
43+
44+
def get_resource_policy(self, resource_arn: str) -> Optional[dict]:
45+
"""Get the resource-based policy for a resource.
46+
47+
Args:
48+
resource_arn: ARN of the resource.
49+
50+
Returns:
51+
The policy as a dict, or None if no policy is attached.
52+
"""
53+
self.logger.info("Getting resource policy for %s", resource_arn)
54+
resp = self.client.get_resource_policy(resourceArn=resource_arn)
55+
return json.loads(resp["policy"]) if "policy" in resp else None
56+
57+
def delete_resource_policy(self, resource_arn: str) -> dict:
58+
"""Delete the resource-based policy from a resource.
59+
60+
Args:
61+
resource_arn: ARN of the resource.
62+
63+
Returns:
64+
Raw boto3 response.
65+
66+
Raises:
67+
ClientError: ResourceNotFoundException if no policy exists.
68+
"""
69+
self.logger.info("Deleting resource policy for %s", resource_arn)
70+
return self.client.delete_resource_policy(resourceArn=resource_arn)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Unit tests for ResourcePolicyClient."""
2+
3+
import json
4+
from unittest.mock import Mock, patch
5+
6+
from bedrock_agentcore.services.resource_policy import ResourcePolicyClient
7+
8+
TEST_REGION = "us-east-1"
9+
TEST_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test-agent"
10+
11+
12+
class TestResourcePolicyClient:
13+
def test_initialization(self):
14+
with patch("boto3.client") as mock_boto:
15+
client = ResourcePolicyClient(region=TEST_REGION)
16+
assert client.region == TEST_REGION
17+
mock_boto.assert_called_once_with(
18+
"bedrock-agentcore-control",
19+
region_name=TEST_REGION,
20+
endpoint_url=f"https://bedrock-agentcore-control.{TEST_REGION}.amazonaws.com",
21+
)
22+
23+
def test_put_serializes_dict_to_json(self):
24+
with patch("boto3.client") as mock_boto:
25+
mock_client = Mock()
26+
mock_boto.return_value = mock_client
27+
policy = {"Version": "2012-10-17", "Statement": []}
28+
mock_client.put_resource_policy.return_value = {"policy": json.dumps(policy)}
29+
30+
client = ResourcePolicyClient(region=TEST_REGION)
31+
result = client.put_resource_policy(TEST_ARN, policy)
32+
33+
mock_client.put_resource_policy.assert_called_once_with(resourceArn=TEST_ARN, policy=json.dumps(policy))
34+
assert result == policy
35+
36+
def test_get_returns_none_when_no_policy(self):
37+
with patch("boto3.client") as mock_boto:
38+
mock_client = Mock()
39+
mock_boto.return_value = mock_client
40+
mock_client.get_resource_policy.return_value = {}
41+
42+
client = ResourcePolicyClient(region=TEST_REGION)
43+
assert client.get_resource_policy(TEST_ARN) is None
44+
45+
def test_get_deserializes_policy(self):
46+
with patch("boto3.client") as mock_boto:
47+
mock_client = Mock()
48+
mock_boto.return_value = mock_client
49+
policy = {"Version": "2012-10-17"}
50+
mock_client.get_resource_policy.return_value = {"policy": json.dumps(policy)}
51+
52+
client = ResourcePolicyClient(region=TEST_REGION)
53+
assert client.get_resource_policy(TEST_ARN) == policy
54+
55+
def test_delete_passes_through(self):
56+
with patch("boto3.client") as mock_boto:
57+
mock_client = Mock()
58+
mock_boto.return_value = mock_client
59+
mock_client.delete_resource_policy.return_value = {}
60+
61+
client = ResourcePolicyClient(region=TEST_REGION)
62+
client.delete_resource_policy(TEST_ARN)
63+
mock_client.delete_resource_policy.assert_called_once_with(resourceArn=TEST_ARN)

tests_integ/services/__init__.py

Whitespace-only changes.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Integration tests for ResourcePolicyClient.
2+
3+
E2e tests against real AWS resources. No mocking.
4+
5+
Requires:
6+
RESOURCE_POLICY_TEST_ARN: ARN of an Agent Runtime to attach policies to
7+
RESOURCE_POLICY_TEST_PRINCIPAL: IAM principal ARN to use in test policies
8+
BEDROCK_TEST_REGION: AWS region (default: us-west-2)
9+
10+
Run: pytest -xvs tests_integ/services/test_resource_policy.py
11+
"""
12+
13+
import json
14+
import os
15+
16+
import pytest
17+
from botocore.exceptions import ClientError
18+
19+
from bedrock_agentcore.services.resource_policy import ResourcePolicyClient
20+
21+
22+
def _make_policy(
23+
resource_arn: str,
24+
principal_arn: str,
25+
action: str = "bedrock-agentcore:InvokeAgentRuntime",
26+
) -> dict:
27+
"""Build a minimal valid policy dict for testing."""
28+
return {
29+
"Version": "2012-10-17",
30+
"Statement": [
31+
{
32+
"Effect": "Allow",
33+
"Principal": {"AWS": principal_arn},
34+
"Action": action,
35+
"Resource": resource_arn,
36+
}
37+
],
38+
}
39+
40+
41+
@pytest.mark.integration
42+
class TestResourcePolicyClient:
43+
@classmethod
44+
def setup_class(cls):
45+
cls.resource_arn = os.environ.get("RESOURCE_POLICY_TEST_ARN")
46+
cls.principal_arn = os.environ.get("RESOURCE_POLICY_TEST_PRINCIPAL")
47+
cls.region = os.environ.get("BEDROCK_TEST_REGION", "us-west-2")
48+
49+
if not cls.resource_arn:
50+
pytest.fail("RESOURCE_POLICY_TEST_ARN env var is required")
51+
if not cls.principal_arn:
52+
pytest.fail("RESOURCE_POLICY_TEST_PRINCIPAL env var is required")
53+
54+
cls.client = ResourcePolicyClient(region=cls.region)
55+
56+
def setup_method(self):
57+
"""Runs before each test — ensures no policy is attached."""
58+
try:
59+
self.client.delete_resource_policy(self.resource_arn)
60+
except Exception:
61+
pass
62+
63+
@classmethod
64+
def teardown_class(cls):
65+
"""Remove any policy left by the last test so we don't leave side effects on the resource."""
66+
try:
67+
cls.client.delete_resource_policy(cls.resource_arn)
68+
except Exception:
69+
pass
70+
71+
@pytest.mark.order(1)
72+
def test_get_returns_none_when_no_policy(self):
73+
"""get on a resource with no policy returns None."""
74+
result = self.client.get_resource_policy(self.resource_arn)
75+
assert result is None
76+
77+
@pytest.mark.order(2)
78+
def test_put_get_round_trip(self):
79+
"""put(policy) then get() returns matching policy as a dict."""
80+
policy = _make_policy(self.resource_arn, self.principal_arn)
81+
82+
put_result = self.client.put_resource_policy(self.resource_arn, policy)
83+
assert isinstance(put_result, dict)
84+
assert put_result["Version"] == policy["Version"]
85+
86+
result = self.client.get_resource_policy(self.resource_arn)
87+
assert isinstance(result, dict)
88+
assert result["Version"] == policy["Version"]
89+
assert result["Statement"][0]["Effect"] == "Allow"
90+
assert result["Statement"][0]["Resource"] == self.resource_arn
91+
92+
@pytest.mark.order(3)
93+
def test_put_overwrites(self):
94+
"""put(A) then put(B) then get() returns B."""
95+
policy_a = _make_policy(self.resource_arn, self.principal_arn, action="bedrock-agentcore:InvokeAgentRuntime")
96+
policy_b = _make_policy(self.resource_arn, self.principal_arn, action="bedrock-agentcore:GetAgentCard")
97+
98+
self.client.put_resource_policy(self.resource_arn, policy_a)
99+
self.client.put_resource_policy(self.resource_arn, policy_b)
100+
result = self.client.get_resource_policy(self.resource_arn)
101+
102+
assert result["Statement"][0]["Action"] == "bedrock-agentcore:GetAgentCard"
103+
104+
@pytest.mark.order(4)
105+
def test_delete_removes_policy(self):
106+
"""put(policy) then delete() then get() returns None."""
107+
policy = _make_policy(self.resource_arn, self.principal_arn)
108+
self.client.put_resource_policy(self.resource_arn, policy)
109+
self.client.delete_resource_policy(self.resource_arn)
110+
111+
result = self.client.get_resource_policy(self.resource_arn)
112+
assert result is None
113+
114+
@pytest.mark.order(5)
115+
def test_delete_on_no_policy_raises(self):
116+
"""delete on a resource with no policy raises ResourceNotFoundException."""
117+
with pytest.raises(ClientError) as exc_info:
118+
self.client.delete_resource_policy(self.resource_arn)
119+
assert exc_info.value.response["Error"]["Code"] == "ResourceNotFoundException"
120+
121+
@pytest.mark.order(6)
122+
def test_dict_and_string_equivalence(self):
123+
"""put(dict) and put(json.dumps(dict)) produce the same get() result."""
124+
policy = _make_policy(self.resource_arn, self.principal_arn)
125+
126+
self.client.put_resource_policy(self.resource_arn, policy)
127+
result_from_dict = self.client.get_resource_policy(self.resource_arn)
128+
129+
self.client.put_resource_policy(self.resource_arn, json.dumps(policy))
130+
result_from_str = self.client.get_resource_policy(self.resource_arn)
131+
132+
assert result_from_dict == result_from_str

0 commit comments

Comments
 (0)