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());
    }

    /** Name prefix of every cross-account access role Manager generates, in any account. Mirrors
     *  {@code CrossAccountRoleTemplateFactory#ROLE_NAME_PREFIX} in cloudforge-manager (duplicated
     *  because cloudforge-api cannot depend on it; keep the two in sync). Scopes
     *  {@link #crossAccountAssumeRoleStatement} to these roles instead of {@code "*"}. */
    private static final String CROSS_ACCOUNT_ROLE_NAME_PREFIX = "CloudForgeManagerAccess-";

    /**
     * Allows Manager's task role to call {@code sts:AssumeRole} on a connected account's
     * {@code CloudForgeManagerAccess-*} role. Cross-account access needs both this grant on the
     * caller and a matching trust policy on the target role ({@code CrossAccountRoleTemplateFactory});
     * without this statement {@code sts:AssumeRole} returns {@code AccessDenied} even when the
     * target's trust policy is correct.
     */
    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());
    }

    /**
     * Lets Manager's own task role call {@code aws-marketplace:GetEntitlements} — the API
     * {@code MarketplaceEntitlementService} (cloudforge-manager) self-checks against on a
     * schedule, using this same task role's own default credentials (no separate seller-side
     * credentials involved). Granted to every Manager deployment unconditionally: Manager checks
     * on every AWS deployment and lets AWS's subscription record decide the outcome, so a
     * non-subscriber simply gets an empty entitlement list. {@code Resource: "*"} because {@code
     * GetEntitlements} supports no resource-level permissions or condition keys at all (AWS's
     * own service-authorization reference for this service lists none). The product code
     * {@code MarketplaceEntitlementService} checks against is a constant compiled into
     * cloudforge-manager, never sourced from deploy-time config.
     */
    public static Optional<PolicyStatement> marketplaceEntitlementStatement(SystemContext ctx) {
        if (!isCloudForgeManager(ctx)) {
            return Optional.empty();
        }
        return Optional.of(PolicyStatement.Builder.create()
            .sid("CloudForgeManagerMarketplaceEntitlement")
            .actions(List.of("aws-marketplace:GetEntitlements"))
            .resources(List.of("*"))
            .build());
    }

    public static void addOperatorBaselineToStatements(SystemContext ctx, List<PolicyStatement> statements) {
        operatorBaselineStatement(ctx).ifPresent(statements::add);
        crossAccountAssumeRoleStatement(ctx).ifPresent(statements::add);
        marketplaceEntitlementStatement(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);
        marketplaceEntitlementStatement(ctx).ifPresent(role::addToPolicy);
    }

    /** Tag key applied by {@code ApplicationFargateStack}/{@code ApplicationEc2Stack} via
     * {@code Tags.of(this).add(...)} and read by Manager's {@code StackListingPolicy.TAG_MANAGED}.
     * The three sites share no common module, so keep them in sync. */
    private static final String TAG_MANAGED = "cloudforge:managed";

    /**
     * Resource-name patterns for the IAM roles a deployed app's stack creates that Manager's
     * operator role must be able to create, manage, and delete (see {@code iamRoleCreate}/{@code
     * iamRoleManage} below for why this is not a tag condition). Each pattern targets a name
     * segment that survives CloudFormation's physical-name truncation (IAM's 64-character limit
     * keeps only a prefix of the logical id plus a random suffix) -- for a role nested deeply
     * enough (e.g. {@code <App>Application/<App>Vpc/VpcFlowlogIAMRole}), that segment can be
     * truncated away entirely for long stack/app names, leaving nothing stable to match. RDS
     * Enhanced Monitoring, AWS Backup selection, and VPC Flow Log roles are all covered by giving
     * them an explicit {@code roleName} at creation instead (see each pattern's own comment below).
     * {@code ComplianceCloudTrailLogsRole} remains a known gap -- see the comment right after this
     * list for why.
     */
    private static final List<String> IAM_ROLE_MANAGE_RESOURCES = List.of(
        // Task/task-execution roles for each IAMProfile (Extended/Standard/MinimalIAMConfiguration).
        // These are stack-top-level constructs, so "SystemContext...Task..." survives truncation.
        "arn:aws:iam::*:role/*SystemContextExtendedTask*",
        "arn:aws:iam::*:role/*SystemContextStandardTask*",
        "arn:aws:iam::*:role/*SystemContextMinimalTask*",
        // Role of CDK's singleton log-retention Lambda; same name shape in every app.
        "arn:aws:iam::*:role/*LogRetention*",
        // Role of CDK's singleton custom-resource provider (backs Custom::S3AutoDeleteObjects,
        // among others). "679f53fac002430cb0da5b7982bd2287" is CDK's fixed construct-id hash for
        // this provider, so it is stable; only the prefix that survives truncation is matched.
        "arn:aws:iam::*:role/*AWS679f53fac002430cb0da5b798*",
        "arn:aws:iam::*:role/*CustomS3AutoDeleteObjectsCus*",
        // RDS Enhanced Monitoring role. RdsFactory gives it an explicit roleName so this pattern
        // can match it; see RdsFactory#createMonitoringRole.
        "arn:aws:iam::*:role/*-CfcRdsMonitor",
        // AWS Backup selection role. BackupFactory gives it an explicit roleName for the same
        // reason as the RDS monitoring role above; see BackupFactory#createSelectionRole -- its
        // auto-generated physical name otherwise truncates to a generic tail shared with unrelated
        // roles in the same stack, leaving nothing stable to match, and a rollback that needs to
        // delete it fails with AccessDenied and lands the stack in ROLLBACK_FAILED.
        "arn:aws:iam::*:role/*-CfcBackupSelection",
        // VPC Flow Log delivery role. FlowLogFactory gives it an explicit roleName for the same
        // reason; see FlowLogFactory#createFlowLogRole.
        "arn:aws:iam::*:role/*-CfcFlowLog"
    );

    // KNOWN GAP: CloudTrail's CloudWatch Logs delivery role, auto-created by ComplianceFactory's
    // use of the high-level `Trail` L2 construct (`Trail.Builder...sendToCloudWatchLogs(true)`),
    // is NOT covered by any pattern above and remains unmatchable the same way the RDS/Backup/
    // FlowLog roles were before their fixes. Unlike those three, `Trail.Builder` has no
    // `cloudWatchLogsRole`-style override -- there is no supported way to give this auto-created
    // role an explicit, stable name from the L2 API. Closing this gap would mean dropping to the
    // L1 `CfnTrail` construct (or reaching into the L2 Trail's internal child construct via
    // `addPropertyOverride`, an unverified escape hatch) to assign an explicit RoleName, which is
    // a larger, riskier change than the three fixes above and has not been attempted yet. Until
    // then, a stack whose rollback needs to delete this specific role can still land in
    // ROLLBACK_FAILED and need the same manual cleanup the fixes above now avoid for the others.

    /**
     * Direct-deploy IAM ({@code CFN_DEPLOY} capability, backing the {@code deploy:create}
     * {@code ManagerPolicyCatalog} policy), scoped to CloudForge-tagged resources rather than a
     * flat action allow-list, because {@code CreateStack}/{@code UpdateStack}/{@code PassRole} can
     * create or modify arbitrary infrastructure.
     *
     * <p>Create and update use separate statements because the condition key differs by lifecycle
     * stage: {@code aws:RequestTag} evaluates tags on a resource being created, while
     * {@code aws:ResourceTag} evaluates an existing resource's tags. Statements are OR'd, so
     * whichever path applies succeeds. {@code iam:PassRole} is scoped by {@code iam:ResourceTag}
     * on the passed role, relying on CDK's stack-level tag propagation to tag the stack's roles.</p>
     *
     * <p><b>Caveat:</b> these conditions are covered by unit tests only, not an end-to-end
     * deploy against an AWS account. Verify {@code CreateChangeSet}/{@code ExecuteChangeSet}
     * behavior under these conditions before relying on this as the sole guardrail.</p>
     */
    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 cannot use a tag condition:
        //   1. They accept no Tags parameter, so aws:RequestTag never matches.
        //   2. They authorize against the stack, and for a CREATE change set CloudFormation only
        //      applies tags on execution, so a new stack in REVIEW_IN_PROGRESS has no tags yet.
        // CreateChangeSet's tag condition already gates who can start a managed change set.
        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 is not enough: CloudFormation creates the application's IAM roles
        // (task and task-execution roles) with the deploying principal's credentials.
        // CAPABILITY_NAMED_IAM only acknowledges IAM resources; it grants nothing. Without these
        // grants, an automatic rollback also cannot delete roles already created.
        //
        // Scoped by resource name rather than iam:RequestTag: CloudFormation's AWS::IAM::Role
        // provider does not reliably pass template tags to CreateRole in a form iam:RequestTag
        // can evaluate, which fails with UnauthorizedTaggingOperation even when the template
        // carries the cloudforge:managed tag. ARN patterns do not depend on tags.
        PolicyStatement iamRoleCreate = PolicyStatement.Builder.create()
            .sid("CloudForgeManagerDeployIamRoleCreate")
            .actions(List.of("iam:CreateRole", "iam:TagRole"))
            .resources(IAM_ROLE_MANAGE_RESOURCES)
            .build();
        // Scoped by resource name rather than iam:ResourceTag: an automatic rollback can issue
        // DeleteRole/DetachRolePolicy seconds after CreateRole, before the new role's tags are
        // visible to IAM condition evaluation (eventual consistency). ARN patterns are evaluated
        // synchronously. See IAM_ROLE_MANAGE_RESOURCES for coverage and known gaps.
        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 also a Lambda function. Without these grants,
        // lambda:CreateFunction is denied and rollback fails on lambda:DeleteFunction
        // (ROLLBACK_FAILED). Scoped by name, like the role above.
        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();
        // AwsDirectDeployer uploads the template to S3 before CreateStack/CreateChangeSet, which
        // needs its own grant. Scoped to the deployer's fixed bucket-name prefix.
        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();
        // AwsDirectDeployer.deploy() also publishes CDK assets (via LocalStackCdkAssetPublisher,
        // for AWS and emulators alike) to the bucket named in the asset manifest, following the
        // CDK bootstrap convention cdk-<qualifier>-assets-<account>-<region>. Custom qualifiers are
        // not supported, so only the default "hnb659fds" is granted.
        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 the template's BootstrapVersion SSM parameter reference with
        // the caller's credentials, so the task role needs ssm:GetParameters on it.
        // (AwsDirectDeployer.resolveCdkBootstrapParameters removes the reference for local
        // emulator targets only.)
        PolicyStatement cdkBootstrapParameter = PolicyStatement.Builder.create()
            .sid("CloudForgeManagerDeployCdkBootstrapParameter")
            .actions(List.of("ssm:GetParameters"))
            .resources(List.of("arn:aws:ssm:*:*:parameter/cdk-bootstrap/hnb659fds/*"))
            .build();
        // CloudFormation creates the stack's VPC/EFS/ALB/ECS-cluster resources under the
        // caller's identity, so Manager's role needs those service actions too. (PermissionMatrix
        // governs the deployed app's task role, not Manager's.)
        // Unconditioned, like changeSetLifecycle: tag-conditioning ~150 actions across six
        // services would hit the same tag-propagation race as iamRoleManage, and these resources
        // have no stable name pattern. The cloudforge:managed condition on stack create/update
        // bounds them. See OperatorProvisioningPermissionMatrix.
        //
        // Split into two statements because a role's inline policies share a single 10,240-byte
        // budget; attachDeployCapabilities attaches each half as its own customer-managed policy.
        // See OperatorProvisioningPermissionMatrix#getNetworkPermissions for the size accounting.
        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);
    }

    /**
     * Attaches {@link #deployStatements} to the role.
     * Unlike {@link #attachOperatorBaselinePolicies}, this is gated behind
     * {@link com.cloudforge.core.config.DeploymentConfig#managerDirectDeployEnabled}: it is a
     * higher-privilege tier that must be requested explicitly per deployment. The Standard,
     * Extended, and Minimal IAM configurations call this unconditionally; the opt-in check lives
     * here so it has a single owner.
     */
    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);
            }
        }
        addDeployCapabilitiesNagSuppressions(role);
    }

    /**
     * Adds {@link #deployStatements} to a caller-assembled statements list, except the two
     * target-infrastructure statements (see {@link #attachDeployTargetInfrastructurePolicies}).
     * Used where a role's policy is built from a statements list before the role exists (e.g. the
     * PRODUCTION Fargate task-role branches in {@code MinimalIAMConfiguration}), so
     * {@link #attachDeployCapabilities} cannot be used. No-op unless
     * {@code managerDirectDeployEnabled} is set.
     */
    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);
            }
        }
    }

    /**
     * Attaches the two target-infrastructure statements from {@link #deployStatements}, each as
     * its own customer-managed policy. Companion to {@link #addDeployCapabilitiesToStatements}.
     * These statements are large (~150 actions), and a role's inline policies share a single
     * 10,240-byte budget, as do all statements in one managed policy, so each gets a separate
     * managed policy with its own size budget.
     */
    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);
    }
}