ManagerOperatorIamSupport.java
package com.cloudforgeci.api.core.iam;
import com.cloudforge.core.enums.IAMProfile;
import com.cloudforge.core.manager.ManagerAwsCapabilityCatalog;
import com.cloudforgeci.api.core.SystemContext;
import com.cloudforgeci.api.deploy.aws.AwsDirectDeployer;
import io.github.cdklabs.cdknag.NagPackSuppression;
import io.github.cdklabs.cdknag.NagSuppressions;
import software.amazon.awscdk.services.iam.ManagedPolicy;
import software.amazon.awscdk.services.iam.PolicyStatement;
import software.amazon.awscdk.services.iam.Role;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Attaches {@link ManagerAwsCapabilityCatalog} baseline operator IAM to CloudForge Manager
* task/instance roles at CDK synthesis time.
*/
public final class ManagerOperatorIamSupport {
public static final String APPLICATION_ID = "cloudforge-manager";
public static final String OPERATOR_POLICY_SID = "CloudForgeManagerOperatorBaseline";
private ManagerOperatorIamSupport() {
}
public static boolean isCloudForgeManager(SystemContext ctx) {
if (ctx.applicationSpec.get()
.map(spec -> APPLICATION_ID.equals(spec.applicationId()))
.orElse(false)) {
return true;
}
return APPLICATION_ID.equals(ctx.cfc.applicationId());
}
public static Optional<PolicyStatement> operatorBaselineStatement(SystemContext ctx) {
if (!isCloudForgeManager(ctx)) {
return Optional.empty();
}
List<String> actions = new ArrayList<>(ManagerAwsCapabilityCatalog.operatorBaselineIamActions());
return Optional.of(PolicyStatement.Builder.create()
.sid(OPERATOR_POLICY_SID)
.actions(actions)
.resources(List.of("*"))
.build());
}
/** Every cross-account-connect role this app itself ever generates carries this exact name
* prefix regardless of which AWS account it lives in (see {@code
* CrossAccountRoleTemplateFactory#ROLE_NAME_PREFIX} in cloudforge-manager — duplicated here
* rather than shared, since cloudforge-api can't depend on cloudforge-manager; keep the two
* in sync by hand). Scoping {@link #crossAccountAssumeRoleStatement}'s resource to this
* prefix instead of {@code Resource: "*"} is exactly as narrow as the actual capability
* needs to be. */
private static final String CROSS_ACCOUNT_ROLE_NAME_PREFIX = "CloudForgeManagerAccess-";
/**
* Lets Manager's own task role actually CALL {@code sts:AssumeRole} against a connected
* account's {@code CloudForgeManagerAccess-*} role — the other half of the cross-account
* connect feature ({@code StsAssumeRoleService}/{@code CrossAccountRoleTemplateFactory}): a
* correct trust policy on the TARGET role means nothing if Manager's own IAM policy never
* grants it permission to make the call in the first place. A connected account's deployed
* trust policy can match byte-for-byte what Manager itself generated and {@code
* sts:AssumeRole} still come back {@code AccessDenied} without this — the grant had never
* actually existed anywhere in either repo, despite {@code CrossAccountRoleTemplateFactory}'s
* own javadoc claiming this class already provided it.
*/
public static Optional<PolicyStatement> crossAccountAssumeRoleStatement(SystemContext ctx) {
if (!isCloudForgeManager(ctx)) {
return Optional.empty();
}
return Optional.of(PolicyStatement.Builder.create()
.sid("CloudForgeManagerCrossAccountAssumeRole")
.actions(List.of("sts:AssumeRole"))
.resources(List.of("arn:aws:iam::*:role/" + CROSS_ACCOUNT_ROLE_NAME_PREFIX + "*"))
.build());
}
public static void addOperatorBaselineToStatements(SystemContext ctx, List<PolicyStatement> statements) {
operatorBaselineStatement(ctx).ifPresent(statements::add);
crossAccountAssumeRoleStatement(ctx).ifPresent(statements::add);
}
public static void attachOperatorBaselinePolicies(SystemContext ctx, Role role) {
operatorBaselineStatement(ctx).ifPresent(statement -> {
role.addToPolicy(statement);
NagSuppressions.addResourceSuppressions(
role,
List.of(
NagPackSuppression.builder()
.id("AwsSolutions-IAM5")
.reason("CloudForge Manager operator APIs (CFN inventory/delete, ECS lifecycle, "
+ "RDS snapshot/restore) require account-scoped resources per "
+ ManagerAwsCapabilityCatalog.CATALOG_VERSION
+ ". Application-layer RBAC governs panel access.")
.build()
),
Boolean.TRUE);
});
crossAccountAssumeRoleStatement(ctx).ifPresent(role::addToPolicy);
}
/** Same tag key {@code ApplicationFargateStack}/{@code ApplicationEc2Stack} apply via
* {@code Tags.of(this).add(...)}, and {@code StackListingPolicy.TAG_MANAGED} reads on the
* Manager-inventory side — one convention, three independent read/write sites (CDK doesn't
* offer a shared constants module below cloudforge-core that all of these could import). */
private static final String TAG_MANAGED = "cloudforge:managed";
/**
* Direct-deploy IAM ({@code CFN_DEPLOY} capability, backing the {@code deploy:create}
* {@code ManagerPolicyCatalog} policy) — scoped to CloudForge-tagged resources instead of a
* flat action allow-list, since {@code CreateStack}/{@code UpdateStack}/{@code PassRole} can
* create or modify arbitrary infrastructure, unlike every other capability
* {@link #attachOperatorBaselinePolicies} attaches.
*
* <p>Three separate statements, not one, because the relevant condition key differs by
* lifecycle stage: {@code aws:RequestTag} only evaluates the tags being set on a resource
* that doesn't exist yet (the create path), {@code aws:ResourceTag} only evaluates an
* existing resource's current tags (the update path) — a single statement can't express
* "either," but IAM statements are OR'd at the policy level, so attaching both lets whichever
* path is actually being taken succeed on its own terms. {@code iam:PassRole} gets its own
* statement scoped by {@code iam:ResourceTag} on the role being passed, relying on CDK's
* stack-level tag propagation to also tag IAM roles the stack creates.</p>
*
* <p><b>Caveat, stated plainly:</b> not validated against a real AWS account — this
* environment has no AWS credentials to confirm {@code CreateChangeSet}/{@code
* ExecuteChangeSet} (which can create <em>or</em> update a stack depending on whether the
* target already exists) behave exactly as documented under these conditions in practice.
* Verify with a real deploy before treating this as the sole guardrail.</p>
*/
/**
* Resource-name patterns for the IAM roles a deployed app's own stack creates that Manager's
* operator role must be able to create/manage/tear down (see {@code iamRoleCreate}/{@code
* iamRoleManage} below for why this can't be a tag condition instead). Each pattern targets a
* name segment that survives CloudFormation's physical-role-name truncation (IAM's
* 64-character role-name limit forces CloudFormation to keep only a prefix of the full
* logical id plus a random hash suffix) — not every role this platform's own constructs
* create can be covered this way, see the caveat below.
*
* <p><b>Known gap, not coverable by this mechanism</b>: {@code VpcFlowlogIAMRole}, {@code
* BackupSelectionRole}, and {@code ComplianceCloudTrailLogsRole}. Each is nested deep enough
* under an app's own construct path (e.g. {@code <App>Application/<App>Vpc/VpcFlowlogIAMRole})
* that for a long-enough stack/app name, CloudFormation's truncation cuts the physical name
* off before any of these three ever appear — {@code VpcFlowlogIAMRole} and {@code
* BackupSelectionRole} can truncate to the exact same physical-name prefix on the same stack,
* so no name pattern can even distinguish between them, let alone reliably match either.
* {@code ComplianceCloudTrailLogsRole} truncates to an app-name-derived fragment that isn't a
* fixed platform string at all, so no pattern written today can match it for a
* differently-named app either. Adding more name patterns can't close this gap — the
* identifying suffix is destroyed before it reaches the physical name, not merely unmatched.
* The real fix is a stable identifier that survives truncation entirely, e.g. a shared IAM
* path prefix (a role's {@code path} isn't subject to the same length-driven truncation as
* its name) assigned to every role this platform's own factories create, with Manager's
* operator policy scoped to that path instead of a name substring — a broader change across
* every factory that creates a {@code Role} (VpcFactory, BackupFactory, compliance CloudTrail
* logging, ...), out of scope here. Until then, a deploy whose rollback needs to delete one of
* these three roles gets stuck in {@code ROLLBACK_FAILED}, needing manual cleanup with
* elevated (non-Manager) credentials.</p>
*/
private static final List<String> IAM_ROLE_MANAGE_RESOURCES = List.of(
// Every IAMProfile variant's task/task-execution role (ExtendedIAMConfiguration/
// StandardIAMConfiguration/MinimalIAMConfiguration). All three are emitted as a flat,
// stack-top-level construct (never nested under an app's own construct path), so
// "SystemContext...Task..." reliably survives truncation regardless of stack/app name
// length.
"arn:aws:iam::*:role/*SystemContextExtendedTask*",
"arn:aws:iam::*:role/*SystemContextStandardTask*",
"arn:aws:iam::*:role/*SystemContextMinimalTask*",
// CDK's one shared singleton log-retention Lambda's role, identical name shape across
// every app.
"arn:aws:iam::*:role/*LogRetention*",
// CDK's other shared singleton custom-resource-provider framework role, backing (among
// other built-ins) Custom::S3AutoDeleteObjects. "679f53fac002430cb0da5b7982bd2287" is
// CDK's own fixed construct-id hash for this framework (not a per-stack random value), so
// it's as stable a match as "LogRetention" above; matched on a shortened prefix of it
// since that's the portion that survives truncation.
"arn:aws:iam::*:role/*AWS679f53fac002430cb0da5b798*",
"arn:aws:iam::*:role/*CustomS3AutoDeleteObjectsCus*",
// RDS Enhanced Monitoring role, built with an explicit roleName by RdsFactory precisely
// so this pattern can rely on it -- see RdsFactory#createMonitoringRole's own javadoc for
// why the auto-created default (a role CloudFormation names itself, nested deep enough
// under the database instance's own construct to get truncated away for a long enough
// stack/app name) isn't safe to pattern-match here the way the other entries in this list
// are.
"arn:aws:iam::*:role/*-CfcRdsMonitor"
);
public static List<PolicyStatement> deployStatements(SystemContext ctx) {
if (!isCloudForgeManager(ctx)) {
return List.of();
}
PolicyStatement create = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployCreate")
.actions(List.of(
"cloudformation:CreateStack",
"cloudformation:CreateChangeSet"))
.resources(List.of("*"))
.conditions(Map.of("StringEquals", Map.of("aws:RequestTag/" + TAG_MANAGED, "true")))
.build();
PolicyStatement update = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployUpdate")
.actions(List.of(
"cloudformation:UpdateStack",
"cloudformation:CreateChangeSet"))
.resources(List.of("*"))
.conditions(Map.of("StringEquals", Map.of("aws:ResourceTag/" + TAG_MANAGED, "true")))
.build();
// DescribeChangeSet/ExecuteChangeSet/DeleteChangeSet can't share a tag condition with
// CreateChangeSet at all:
// 1. Those three actions don't accept a Tags parameter, so aws:RequestTag never has a
// value to compare against -- an unconditional deny that looks like a scoped one.
// 2. aws:ResourceTag doesn't work either: these actions authorize against the STACK the
// change set belongs to, and for a CREATE-type change set specifically, CloudFormation
// only applies a change set's Tags to the stack when it EXECUTES, not when it's
// merely created. A brand-new stack sits in REVIEW_IN_PROGRESS with zero tags for as
// long as its first change set is still pending, with no tag to match against yet.
// No condition at all, matching this class's own SC_PROVISION precedent below ("the
// underlying service's own model is the guardrail, not a tag condition on Manager's
// role") -- CreateChangeSet's own tag requirement already gates who can start a managed
// change set in the first place; once one exists, finishing what Manager itself just
// started doesn't need a second gate that these actions can't structurally satisfy.
PolicyStatement changeSetLifecycle = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployChangeSetLifecycle")
.actions(List.of(
"cloudformation:DescribeChangeSet",
"cloudformation:ExecuteChangeSet",
"cloudformation:DeleteChangeSet"))
.resources(List.of("*"))
.build();
PolicyStatement passRole = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployPassRole")
.actions(List.of("iam:PassRole"))
.resources(List.of("*"))
.conditions(Map.of("StringEquals", Map.of("iam:ResourceTag/" + TAG_MANAGED, "true")))
.build();
// iam:PassRole alone isn't enough -- CloudFormation creates and manages the target
// application's own IAM roles (task role, task execution role -- the standard ECS/Fargate
// pattern every deployed app needs) using the DEPLOYING PRINCIPAL's own credentials, the
// same way it does for every other resource type in the template. CAPABILITY_NAMED_IAM on
// the API call only acknowledges the template creates IAM resources; it doesn't itself
// grant the permission to create/manage them. Without the grants below, an automatic
// rollback triggered by an unrelated resource failure can't tear back down the roles
// CloudFormation had already created either, for the same missing-permission reason.
//
// Resource-name-scoped rather than tag-conditioned on iam:RequestTag, even though
// CreateRole/TagRole carry the request's own Tags parameter synchronously with no
// propagation delay to race against in principle: CloudFormation's IAM::Role resource
// provider doesn't reliably pass the template's tags through to the CreateRole API call's
// own Tags parameter in a way iam:RequestTag can evaluate against, surfacing as a
// HandlerErrorCode: UnauthorizedTaggingOperation on iam:CreateRole even when the
// synthesized template carries the correct inline cloudforge:managed tag in the role's
// own Properties.Tags -- a real, documented CloudFormation/IAM interaction gap, not a
// config mistake on this class's part. Same fix as iamRoleManage below, for the same
// reason: resource-name-pattern matching is evaluated synchronously against the request
// and doesn't depend on any tag mechanism at all.
PolicyStatement iamRoleCreate = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployIamRoleCreate")
.actions(List.of("iam:CreateRole", "iam:TagRole"))
.resources(IAM_ROLE_MANAGE_RESOURCES)
.build();
// Deliberately resource-name-scoped, not iam:ResourceTag-conditioned: CloudFormation's
// automatic rollback can issue DeleteRole/DetachRolePolicy within seconds of CreateRole
// succeeding (a role created just before some unrelated resource in the same stack
// failed) -- faster than the role's own just-applied tags reliably propagate into IAM's
// tag-based condition evaluation, a real eventual-consistency race on iam:ResourceTag
// specifically, not a missing/wrong tag. A resource ARN pattern match is evaluated
// synchronously against the request (same reasoning as templateBucket's fixed prefix
// below), so it can't race. See IAM_ROLE_MANAGE_RESOURCES's own javadoc for what's
// covered, what isn't, and why.
PolicyStatement iamRoleManage = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployIamRoleManage")
.actions(List.of(
"iam:DeleteRole",
"iam:GetRole",
"iam:UpdateRole",
"iam:UpdateAssumeRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
"iam:UntagRole"))
.resources(IAM_ROLE_MANAGE_RESOURCES)
.build();
// The LogRetention custom resource is a Lambda *function*, not just the IAM role above --
// without these, lambda:CreateFunction is denied outright (nothing else here grants any
// lambda:* action), and the same gap then blocks the stack's own automatic rollback on
// lambda:DeleteFunction, leaving it stuck in ROLLBACK_FAILED. Same resource-name-pattern
// scoping as the role above, for the same reason -- this is CDK's one shared singleton
// log-retention Lambda, identical name shape across every app.
PolicyStatement logRetentionFunctionManage = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployLogRetentionFunctionManage")
.actions(List.of(
"lambda:CreateFunction",
"lambda:DeleteFunction",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:AddPermission",
"lambda:RemovePermission",
"lambda:GetPolicy",
"lambda:TagResource",
"lambda:UntagResource",
"lambda:ListTags",
"lambda:InvokeFunction"))
.resources(List.of("arn:aws:lambda:*:*:function:*LogRetention*"))
.build();
// The very gap this class's own "Caveat, stated plainly" javadoc above warns about:
// AwsDirectDeployer uploads the template to a bucket before CreateStack/CreateChangeSet
// ever runs, which needs its own S3 grant -- CreateChangeSet's tag condition alone doesn't
// cover it. S3 bucket ARNs carry no account segment, so a fixed prefix match is exactly as
// scoped as the tag-conditioned statements above, without needing a matching condition key.
PolicyStatement templateBucket = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployTemplateBucket")
.actions(List.of("s3:*"))
.resources(List.of(
"arn:aws:s3:::" + AwsDirectDeployer.TEMPLATE_BUCKET_PREFIX + "*",
"arn:aws:s3:::" + AwsDirectDeployer.TEMPLATE_BUCKET_PREFIX + "*/*"))
.build();
// The template bucket above isn't the only S3 grant needed: AwsDirectDeployer.deploy()
// always calls LocalStackCdkAssetPublisher.publish() -- for real AWS as much as a local
// emulator, that class's own javadoc says -- which uploads to whatever bucket the
// synthesized CDK asset manifest names, the standard CDK bootstrap convention
// (cdk-<qualifier>-assets-<account>-<region>). This app's deploy path never runs `cdk
// bootstrap` and doesn't support a custom qualifier, so the default ("hnb659fds") is the
// only value ever used here.
PolicyStatement cdkAssetBucket = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployCdkAssetBucket")
.actions(List.of("s3:*"))
.resources(List.of(
"arn:aws:s3:::cdk-hnb659fds-assets-*",
"arn:aws:s3:::cdk-hnb659fds-assets-*/*"))
.build();
// CloudFormation resolves a template's BootstrapVersion AWS::SSM::Parameter::Value<String>
// dynamic reference using the caller's own credentials, not a service-linked role -- so
// Manager's task role itself needs ssm:GetParameters on this parameter, the same way it
// needs the S3 grants above, regardless of whether the account has ever run a real `cdk
// bootstrap` (AwsDirectDeployer.resolveCdkBootstrapParameters rewrites this reference
// away entirely, but only for local-emulator targets -- see that method's own javadoc for
// why real AWS keeps its template untouched).
PolicyStatement cdkBootstrapParameter = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployCdkBootstrapParameter")
.actions(List.of("ssm:GetParameters"))
.resources(List.of("arn:aws:ssm:*:*:parameter/cdk-bootstrap/hnb659fds/*"))
.build();
// Even with every statement above, a genuinely new stack's own VPC/EFS/ALB/ECS-cluster
// resources still can't be created without this: CloudFormation issues every
// ec2:CreateVpc/elasticfilesystem:CreateFileSystem/ecs:CreateCluster/etc. call under
// Manager's own identity, a layer distinct from the CloudFormation-orchestration and
// IAM-role-lifecycle grants above, and from PermissionMatrix, which governs a *deployed
// app's own* task role, not Manager's.
// See OperatorProvisioningPermissionMatrix's own javadoc for why this stays unconditioned
// like changeSetLifecycle above rather than tag-conditioned: individually splitting ~150
// create-vs-manage-vs-describe actions across six AWS services by RequestTag/ResourceTag
// would reproduce the exact same tag-propagation race iamRoleManage above was fixed for,
// and these resources have no stable name pattern the way IAM roles do to scope by
// instead. The cloudforge:managed condition on create/update above already bounds this:
// every one of these calls only ever fires as part of a stack CloudFormation was told to
// manage under that tag.
//
// Split into two statements, not one: a role's combined inline-policy size across every
// inline policy document it carries is capped at 10,240 bytes total, not a separate
// budget per document. The database/KMS/secrets permissions push the single flat action
// list (212 actions, ~6.3KB alone) over what fits alongside this
// role's other statements even in its own separate inline policy. See
// OperatorProvisioningPermissionMatrix#getNetworkPermissions's own javadoc for the byte
// accounting behind this exact split point; attachDeployCapabilities attaches each half
// as its own customer-managed policy (a separate, larger budget) rather than inline.
PolicyStatement targetInfrastructureNetwork = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployTargetInfrastructureNetwork")
.actions(OperatorProvisioningPermissionMatrix.getNetworkPermissions(IAMProfile.EXTENDED))
.resources(List.of("*"))
.build();
PolicyStatement targetInfrastructureComputeData = PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployTargetInfrastructureComputeData")
.actions(OperatorProvisioningPermissionMatrix.getComputeAndDataPermissions(IAMProfile.EXTENDED, true))
.resources(List.of("*"))
.build();
return List.of(create, update, passRole, templateBucket, cdkAssetBucket, cdkBootstrapParameter,
changeSetLifecycle, iamRoleCreate, iamRoleManage, logRetentionFunctionManage,
targetInfrastructureNetwork, targetInfrastructureComputeData);
}
/**
* {@code SC_PROVISION} capability backing {@code deploy:catalog} — deliberately no
* conditions: Service Catalog's own portfolio/product/launch-constraint model is the
* guardrail here (a caller can only provision products actually shared with them), not a
* tag condition on Manager's role. No CFN/IAM permissions appear in this statement at all.
*/
public static Optional<PolicyStatement> catalogProvisionStatement(SystemContext ctx) {
if (!isCloudForgeManager(ctx)) {
return Optional.empty();
}
List<String> actions = new ArrayList<>(ManagerAwsCapabilityCatalog.iamActions(
List.of(ManagerAwsCapabilityCatalog.Capability.SC_PROVISION)));
return Optional.of(PolicyStatement.Builder.create()
.sid("CloudForgeManagerDeployCatalog")
.actions(actions)
.resources(List.of("*"))
.build());
}
/**
* Attaches {@link #deployStatements} and {@link #catalogProvisionStatement} to the role.
* Unlike {@link #attachOperatorBaselinePolicies} (always attached as the operator baseline),
* this is gated behind {@link com.cloudforge.core.config.DeploymentConfig#managerDirectDeployEnabled}
* — it is a materially higher-privilege tier and must be explicitly requested per deployment,
* not inherited automatically from IAM profile or {@link #isCloudForgeManager}. Every Standard/
* Extended/Minimal IAM configuration calls this unconditionally after
* {@link #attachOperatorBaselinePolicies} on the Manager's task/instance role; this method,
* not the call sites, owns the opt-in check so there is exactly one place to get it right.
*/
public static void attachDeployCapabilities(SystemContext ctx, Role role) {
if (!Boolean.TRUE.equals(ctx.cfc.managerDirectDeployEnabled())) {
return;
}
List<PolicyStatement> statements = deployStatements(ctx);
if (statements.isEmpty()) {
return;
}
for (PolicyStatement statement : statements) {
if (isTargetInfrastructureStatement(statement)) {
attachAsOwnManagedPolicy(role, statement);
} else {
role.addToPolicy(statement);
}
}
catalogProvisionStatement(ctx).ifPresent(role::addToPolicy);
addDeployCapabilitiesNagSuppressions(role);
}
/**
* Deploy-capability statements safe to fold into a CALLER-assembled statements list — every
* one of {@link #deployStatements} except the two target-infrastructure statements (see
* {@link #attachDeployTargetInfrastructurePolicies}'s javadoc for why those can't share a
* document with anything else). Needed alongside {@link #attachDeployCapabilities} rather
* than instead of it: a Fargate task role built PRODUCTION-profile-style (one hand-assembled
* {@code ManagedPolicy} built from a statements list, then attached at role construction —
* see {@code MinimalIAMConfiguration}'s two Fargate task-role branches) can't retrofit
* {@code role.addToPolicy(...)} onto a role that doesn't exist yet the way {@code
* attachDeployCapabilities} does for the DEV/STAGING branch's incrementally-built role. Same
* opt-in check as every other method here — a no-op call when {@code
* managerDirectDeployEnabled} isn't set is always safe.
*/
public static void addDeployCapabilitiesToStatements(SystemContext ctx, List<PolicyStatement> statements) {
if (!Boolean.TRUE.equals(ctx.cfc.managerDirectDeployEnabled())) {
return;
}
for (PolicyStatement statement : deployStatements(ctx)) {
if (!isTargetInfrastructureStatement(statement)) {
statements.add(statement);
}
}
catalogProvisionStatement(ctx).ifPresent(statements::add);
}
/**
* The two target-infrastructure statements from {@link #deployStatements}, each attached to
* {@code role} as its own customer-managed policy — the companion {@code role} needs once
* its other deploy-capability statements arrived via {@link #addDeployCapabilitiesToStatements}
* instead of {@link #attachDeployCapabilities}. Split out from the rest for the same reason
* {@code attachDeployCapabilities} itself splits them: IAM caps a role's combined inline-
* policy size, across every inline document it carries, at 10,240 bytes total — not a
* per-document budget — so the database/KMS/secrets permissions already push a Fargate task
* role's own policy near that ceiling before these two (~150 actions across two AWS services)
* are even added. A managed policy's size budget is independent of a role's inline-policy
* total, which is why these two specifically get their own rather than joining any inline
* document (this one included, despite it being a customer-managed policy built from a
* statements list rather than {@code role.addToPolicy} — its own budget is still shared
* across everything in that SAME list, unlike a freshly attached policy's).
*/
public static void attachDeployTargetInfrastructurePolicies(SystemContext ctx, Role role) {
if (!Boolean.TRUE.equals(ctx.cfc.managerDirectDeployEnabled())) {
return;
}
boolean attachedAny = false;
for (PolicyStatement statement : deployStatements(ctx)) {
if (isTargetInfrastructureStatement(statement)) {
attachAsOwnManagedPolicy(role, statement);
attachedAny = true;
}
}
if (attachedAny) {
addDeployCapabilitiesNagSuppressions(role);
}
}
private static boolean isTargetInfrastructureStatement(PolicyStatement statement) {
String sid = statement.getSid();
return "CloudForgeManagerDeployTargetInfrastructureNetwork".equals(sid)
|| "CloudForgeManagerDeployTargetInfrastructureComputeData".equals(sid);
}
private static void attachAsOwnManagedPolicy(Role role, PolicyStatement statement) {
role.addManagedPolicy(ManagedPolicy.Builder.create(role, statement.getSid() + "Policy")
.statements(List.of(statement))
.build());
}
private static void addDeployCapabilitiesNagSuppressions(Role role) {
NagSuppressions.addResourceSuppressions(
role,
List.of(
NagPackSuppression.builder()
.id("AwsSolutions-IAM5")
.reason("CloudForge Manager direct-deploy actions (CreateStack/UpdateStack/PassRole) "
+ "are scoped by aws:RequestTag/aws:ResourceTag/iam:ResourceTag conditions requiring "
+ "the CloudForge-managed tag convention, not by resource ARN — CDK synthesizes "
+ "resource names dynamically, so a wildcard resource with a tag condition is the "
+ "narrowest expressible scope. Service Catalog provisioning carries no CFN/IAM "
+ "permissions on this role at all.")
.build()
),
Boolean.TRUE);
}
}