Skip to content

Commit 3cba0b7

Browse files
authored
Merge pull request #10 from co-cddo/add-cross-account-access
Add cross-account access support to WebApp
2 parents d31c00b + 3c0c848 commit 3cba0b7

8 files changed

Lines changed: 729 additions & 462 deletions

File tree

docs/api/webapp.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,30 @@ In the development environment, the task role can be assumed by developers with
225225

226226

227227
This feature is **disabled** in production environments.
228+
229+
### Cross-Account Access
230+
231+
When `cross_account_access=True` is set, the construct enables the ECS task to assume
232+
a cross-account role for accessing production data resources from non-production
233+
environments.
234+
235+
```python
236+
WebApp(
237+
app,
238+
deployment_config=deployment_config,
239+
app_config=app_config,
240+
cross_account_access=True,
241+
)
242+
```
243+
244+
**What it does (non-production only):**
245+
246+
- Grants `sts:AssumeRole` permission to the task role for the cross-account role
247+
- Injects `CROSS_ACCOUNT_ROLE_ARN` environment variable into the container
248+
249+
**In production:** No action is taken regardless of the flag value — the task role
250+
should have direct access to resources via IAM policies.
251+
252+
**App-side usage:** Applications should read `CROSS_ACCOUNT_ROLE_ARN` from the
253+
environment and assume the role when creating boto3 sessions. When the variable is
254+
not set (production), a default session with direct credentials is used instead.

docs/getting-started.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,50 @@ app_config = AppConfig(
272272
)
273273
```
274274

275+
### Cross-Account Data Access
276+
277+
If your application needs to access data in the production account when deployed to
278+
the development environment (e.g., querying Athena or reading S3), enable
279+
cross-account access:
280+
281+
```python
282+
WebApp(
283+
app,
284+
deployment_config=deployment_config,
285+
app_config=app_config,
286+
cross_account_access=True, # Enable cross-account role assumption in dev
287+
# ... other parameters
288+
)
289+
```
290+
291+
When enabled and deploying to a non-production environment, the construct automatically:
292+
293+
1. Grants the task role `sts:AssumeRole` permission on the cross-account role
294+
2. Injects `CROSS_ACCOUNT_ROLE_ARN` as a container environment variable
295+
296+
Your application code can then use this environment variable to create a boto3 session
297+
that assumes the cross-account role:
298+
299+
```python
300+
import os
301+
import boto3
302+
303+
def get_session():
304+
role_arn = os.getenv("CROSS_ACCOUNT_ROLE_ARN")
305+
if role_arn:
306+
sts = boto3.client("sts")
307+
creds = sts.assume_role(RoleArn=role_arn, RoleSessionName="app")["Credentials"]
308+
return boto3.Session(
309+
aws_access_key_id=creds["AccessKeyId"],
310+
aws_secret_access_key=creds["SecretAccessKey"],
311+
aws_session_token=creds["SessionToken"],
312+
)
313+
return boto3.Session()
314+
```
315+
316+
In production, `CROSS_ACCOUNT_ROLE_ARN` is not set, so the default session (using the
317+
task role's direct permissions) is used. No code changes needed between environments.
318+
275319
## Next Steps
276320

277321
- [API Reference](api/webapp.md) - Detailed API documentation

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "gds-idea-cdk-constructs"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
description = "A repo for commonly used constructs in the team."
55
readme = "README.md"
66
authors = [

src/gds_idea_cdk_constructs/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ def _apply_config(self, config: dict[str, str]) -> None:
159159
# Derived from domain_name
160160
self.redirect_unauthorised_url = f"{self.domain_name}/401.html"
161161

162+
# Cross-account role for non-prod environments to access production data.
163+
# Hardcoded for now — swap to config["cross_account_role_arn"] when
164+
# the value is available from Parameter Store. See #11.
165+
if self.environment == DeploymentEnvironment.PRODUCTION:
166+
self.cross_account_role_arn: str | None = None
167+
else:
168+
self.cross_account_role_arn = (
169+
f"arn:aws:iam::{DeploymentEnvironment.PRODUCTION.value}"
170+
":role/assume_role_for_development_account"
171+
)
172+
162173
def _fetch_from_parameter_store(self) -> dict[str, str]:
163174
"""Fetch configuration from AWS Systems Manager Parameter Store.
164175

src/gds_idea_cdk_constructs/web_app/stack.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def __init__(
4545
container_props: WebAppContainerProperties | None = None,
4646
task_role: iam.Role | None = None,
4747
disable_waf: bool = False,
48+
cross_account_access: bool = False,
4849
) -> None:
4950
"""Initialize a WebApp stack with containerized application infrastructure.
5051
@@ -76,6 +77,11 @@ def __init__(
7677
for short-term debugging when WAF rules are blocking legitimate traffic.
7778
Never use in production. Disabling WAF removes critical security
7879
protections against common web exploits.**
80+
cross_account_access: Enable cross-account access to production resources.
81+
Defaults to False. When True and deploying to a non-production
82+
environment, grants the task role sts:AssumeRole permission on
83+
the cross-account role and injects CROSS_ACCOUNT_ROLE_ARN as a
84+
container environment variable.
7985
8086
Example:
8187
Basic usage with Cognito authentication::
@@ -140,6 +146,11 @@ def __init__(
140146
if self.deployment_config.environment == DeploymentEnvironment.DEVELOPMENT:
141147
self._add_assume_policy_for_dev()
142148

149+
# Cross-account access to production resources from non-prod environments
150+
self._cross_account_env: dict[str, str] = {}
151+
if cross_account_access:
152+
self._setup_cross_account_access()
153+
143154
logger.info(
144155
f"Creating web app: {self.app_name} with authentication: {authentication}"
145156
)
@@ -186,6 +197,26 @@ def _add_assume_policy_for_dev(self):
186197
"can assume TaskRole for local development"
187198
)
188199

200+
def _setup_cross_account_access(self) -> None:
201+
"""Grant the task role permission to assume the cross-account role
202+
and inject the role ARN as a container environment variable."""
203+
role_arn = self.deployment_config.cross_account_role_arn
204+
if role_arn is None:
205+
logger.info(
206+
"Cross-account access enabled but no role configured "
207+
"for this environment — skipping"
208+
)
209+
return
210+
211+
self.task_role.add_to_policy(
212+
iam.PolicyStatement(
213+
actions=["sts:AssumeRole"],
214+
resources=[role_arn],
215+
)
216+
)
217+
self._cross_account_env = {"CROSS_ACCOUNT_ROLE_ARN": role_arn}
218+
logger.info(f"Cross-account access enabled: {role_arn}")
219+
189220
def _import_existing_resources(self) -> None:
190221
"""Import existing VPC and other shared resources."""
191222
self.vpc = ec2.Vpc.from_lookup(
@@ -296,6 +327,7 @@ def _setup_ecs_resources(
296327
logging=ecs.LogDrivers.aws_logs(stream_prefix=f"{self.app_name}-app"),
297328
environment={
298329
**self._auth_strategy.get_environment_variables(),
330+
**self._cross_account_env,
299331
**environment,
300332
},
301333
)

tests/test_config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ def test_deployment_config_from_dict_derived_fields(test_cdk_env):
9494
assert config.redirect_unauthorised_url == f"{config.domain_name}/401.html"
9595

9696

97+
def test_deployment_config_cross_account_role_arn_dev(dev_cdk_env):
98+
"""Test that cross_account_role_arn is set for non-prod environments."""
99+
config = DeploymentConfig.from_dict(dev_cdk_env, TEST_CONFIG)
100+
assert config.cross_account_role_arn is not None
101+
assert "588077357019" in config.cross_account_role_arn
102+
assert "assume_role_for_development_account" in config.cross_account_role_arn
103+
104+
105+
def test_deployment_config_cross_account_role_arn_prod(prod_cdk_env):
106+
"""Test that cross_account_role_arn is None in production."""
107+
config = DeploymentConfig.from_dict(prod_cdk_env, TEST_CONFIG)
108+
assert config.cross_account_role_arn is None
109+
110+
97111
def test_deployment_config_from_dict_missing_key_raises_error(test_cdk_env):
98112
"""Test that from_dict raises ValueError when required keys are missing."""
99113
incomplete = {"domain_name": "test.example.com"}

tests/web_app/test_stack.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ def dev_deployment_config():
9595
return DeploymentConfig.from_dict(dev_env, TEST_CONFIG)
9696

9797

98+
@pytest.fixture
99+
def prod_cdk_app():
100+
"""Fixture for CDK App using PROD environment."""
101+
account_id = "588077357019"
102+
region = "eu-west-2"
103+
vpc_id = TEST_CONFIG["vpc_id"]
104+
domain_name = TEST_CONFIG["domain_name"]
105+
106+
app = App(context=_build_cdk_context(account_id, region, vpc_id, domain_name))
107+
return app
108+
109+
110+
@pytest.fixture
111+
def prod_deployment_config():
112+
"""Fixture for PROD DeploymentConfig using from_dict."""
113+
prod_env = CdkEnvironment(account="588077357019", region="eu-west-2")
114+
return DeploymentConfig.from_dict(prod_env, TEST_CONFIG)
115+
116+
98117
@pytest.fixture
99118
def webapp_no_auth(cdk_app, deployment_config, app_config):
100119
"""Fixture for WebApp with NoAuth."""
@@ -581,3 +600,124 @@ def test_web_app_stack_invalid_authentication_raises_error(
581600
docker_context_path="tests/fixtures",
582601
dockerfile_path="Dockerfile",
583602
)
603+
604+
605+
# Cross-account access tests
606+
607+
608+
def test_web_app_stack_cross_account_access_in_dev(
609+
dev_cdk_app, dev_deployment_config, app_config
610+
):
611+
"""Test that cross_account_access=True adds IAM policy and env var in dev."""
612+
stack = WebApp(
613+
dev_cdk_app,
614+
dev_deployment_config,
615+
app_config,
616+
authentication=AuthType.NONE,
617+
docker_context_path="tests/fixtures",
618+
dockerfile_path="Dockerfile",
619+
cross_account_access=True,
620+
)
621+
template = Template.from_stack(stack)
622+
623+
# Task role should have sts:AssumeRole policy
624+
template.has_resource_properties(
625+
"AWS::IAM::Policy",
626+
{
627+
"PolicyDocument": {
628+
"Statement": Match.array_with(
629+
[
630+
Match.object_like(
631+
{
632+
"Action": "sts:AssumeRole",
633+
"Effect": "Allow",
634+
"Resource": Match.string_like_regexp(
635+
r".*assume_role_for_development_account"
636+
),
637+
}
638+
)
639+
]
640+
)
641+
}
642+
},
643+
)
644+
645+
# Container should have CROSS_ACCOUNT_ROLE_ARN env var
646+
template.has_resource_properties(
647+
"AWS::ECS::TaskDefinition",
648+
{
649+
"ContainerDefinitions": Match.array_with(
650+
[
651+
Match.object_like(
652+
{
653+
"Environment": Match.array_with(
654+
[
655+
{
656+
"Name": "CROSS_ACCOUNT_ROLE_ARN",
657+
"Value": Match.string_like_regexp(
658+
r".*assume_role_for_development_account"
659+
),
660+
}
661+
]
662+
)
663+
}
664+
)
665+
]
666+
)
667+
},
668+
)
669+
670+
671+
def test_web_app_stack_cross_account_access_disabled_by_default(
672+
cdk_app, deployment_config, app_config
673+
):
674+
"""Test that cross_account_access defaults to False and adds nothing."""
675+
stack = WebApp(
676+
cdk_app,
677+
deployment_config,
678+
app_config,
679+
authentication=AuthType.NONE,
680+
docker_context_path="tests/fixtures",
681+
dockerfile_path="Dockerfile",
682+
)
683+
template = Template.from_stack(stack)
684+
685+
# Should have no IAM policy with sts:AssumeRole for cross-account role
686+
# (the only policies should be for logging, not sts:AssumeRole)
687+
policies = template.find_resources("AWS::IAM::Policy")
688+
for _policy_id, policy in policies.items():
689+
statements = (
690+
policy.get("Properties", {}).get("PolicyDocument", {}).get("Statement", [])
691+
)
692+
for stmt in statements:
693+
if stmt.get("Action") == "sts:AssumeRole":
694+
resource = stmt.get("Resource", "")
695+
assert "assume_role_for_development_account" not in str(resource)
696+
697+
698+
def test_web_app_stack_cross_account_access_true_in_prod(
699+
prod_cdk_app, prod_deployment_config, app_config
700+
):
701+
"""Test that cross_account_access=True in production adds no AssumeRole policy."""
702+
stack = WebApp(
703+
prod_cdk_app,
704+
prod_deployment_config,
705+
app_config,
706+
authentication=AuthType.NONE,
707+
docker_context_path="tests/fixtures",
708+
dockerfile_path="Dockerfile",
709+
cross_account_access=True,
710+
)
711+
template = Template.from_stack(stack)
712+
713+
# Even with cross_account_access=True, production should have no
714+
# sts:AssumeRole policy for the cross-account role
715+
policies = template.find_resources("AWS::IAM::Policy")
716+
for _policy_id, policy in policies.items():
717+
statements = (
718+
policy.get("Properties", {}).get("PolicyDocument", {}).get("Statement", [])
719+
)
720+
for stmt in statements:
721+
if stmt.get("Action") == "sts:AssumeRole":
722+
resource = stmt.get("Resource", "")
723+
assert "assume_role_for_development_account" not in str(resource)

0 commit comments

Comments
 (0)