ManagerAwsCapabilityCatalog.java

package com.cloudforge.core.manager;

import java.util.Arrays;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

/**
 * Versioned catalog of AWS API capabilities CloudForge Manager uses for per-instance
 * operator actions (RDS snapshot/restore/upgrade, ECS lifecycle, CFN inventory/delete).
 *
 * <p>Panel auth/RBAC is separate — this catalog governs generated task-role IAM only.</p>
 */
public final class ManagerAwsCapabilityCatalog {

    public static final String CATALOG_VERSION = "1.4.0";

    private ManagerAwsCapabilityCatalog() {
    }

    public enum Capability {
        CFN_INVENTORY(
            "cloudformation:DescribeStacks",
            "cloudformation:DescribeStackResources",
            "cloudformation:ListStackResources",
            "cloudformation:DescribeStackEvents",
            "cloudformation:ListStacks",
            "cloudformation:GetTemplate"),
        CFN_DELETE("cloudformation:DeleteStack"),
        ECS_DESCRIBE(
            "ecs:DescribeClusters",
            "ecs:DescribeServices",
            "ecs:DescribeTasks",
            "ecs:ListTasks"),
        ECS_UPDATE_SERVICE(
            "ecs:UpdateService",
            "ecs:RegisterTaskDefinition",
            "ecs:DescribeTaskDefinition"),
        ECS_STOP_TASK("ecs:StopTask"),
        RDS_DESCRIBE(
            "rds:DescribeDBInstances",
            "rds:DescribeDBSnapshots"),
        RDS_SNAPSHOT(
            "rds:CreateDBSnapshot",
            "rds:DeleteDBSnapshot",
            // RDS instances created with CopyTagsToSnapshot enabled make AWS call this
            // transparently on the caller's behalf right after CreateDBSnapshot succeeds --
            // without it, snapshot creation itself succeeds but the automatic tag-copy step
            // gets denied.
            "rds:AddTagsToResource"),
        RDS_RESTORE("rds:RestoreDBInstanceFromDBSnapshot"),
        RDS_ENGINE_UPGRADE("rds:ModifyDBInstance"),
        LOGS_READ(
            "logs:DescribeLogGroups",
            "logs:FilterLogEvents"),
        AUDIT_MANAGER_READ(
            "auditmanager:GetAssessment",
            "auditmanager:GetEvidence"),
        /**
         * "Cognito as the whole Users directory" -- the separate, admin-opted-in feature that
         * lets the Users page and its API manage a Cognito User Pool's users directly instead of
         * the local DB (see {@code CognitoUserManagementService}/{@code CognitoPoolLookupService}
         * and {@code manager_auth_backend.cognito_enabled}). Without this in the operator
         * baseline, even the pool-lookup step fails with {@code cognito-idp:ListUserPools}
         * denied before the feature can do anything.
         * {@code ListUserPools} itself has no per-pool resource to scope by (it's what
         * discovers the pool ID in the first place), so this whole group stays {@code
         * Resource: "*"} like every other operator-baseline capability in this catalog.
         */
        COGNITO_USER_MANAGEMENT(
            "cognito-idp:ListUserPools",
            "cognito-idp:DescribeUserPool",
            "cognito-idp:ListUsers",
            "cognito-idp:AdminCreateUser",
            "cognito-idp:AdminDeleteUser",
            "cognito-idp:AdminUpdateUserAttributes",
            "cognito-idp:AdminEnableUser",
            "cognito-idp:AdminDisableUser",
            "cognito-idp:AdminUserGlobalSignOut",
            "cognito-idp:AdminAddUserToGroup",
            "cognito-idp:AdminRemoveUserFromGroup",
            "cognito-idp:AdminListGroupsForUser",
            "cognito-idp:CreateGroup",
            "cognito-idp:ListGroups"),
        /**
         * Direct-deploy path for creating/updating CloudForge-managed AWS infrastructure —
         * {@code deploy:create} in {@code ManagerPolicyCatalog} (admin-only) routes here.
         * Deliberately NOT part of {@link #operatorBaseline()} — unlike every other capability
         * in this catalog, these actions can create/modify arbitrary infrastructure, not just
         * operate on what already exists, so they must never be silently included in the
         * default operator policy. {@code ManagerOperatorIamSupport} (cloudforge-api) attaches
         * these with {@code aws:RequestTag}/{@code aws:ResourceTag}/{@code iam:ResourceTag}
         * conditions scoping them to CloudForge-managed resources — this catalog only lists the
         * actions; the conditions live where the CDK {@code PolicyStatement} actually gets
         * built, since this module has no CDK dependency.
         */
        CFN_DEPLOY(
            "cloudformation:CreateStack",
            "cloudformation:UpdateStack",
            "cloudformation:CreateChangeSet",
            "cloudformation:ExecuteChangeSet",
            "cloudformation:DescribeChangeSet",
            "cloudformation:DeleteChangeSet",
            "iam:PassRole"),
        /**
         * {@code deploy:catalog} (constrained, manager+admin) routes here — Service Catalog
         * provisioning against pre-published products only; no CFN/IAM/EC2 permissions on
         * Manager's own role for this path at all. Also not part of {@link #operatorBaseline()}.
         */
        SC_PROVISION(
            "servicecatalog:ProvisionProduct",
            "servicecatalog:UpdateProvisionedProduct",
            "servicecatalog:TerminateProvisionedProduct",
            "servicecatalog:DescribeProvisionedProduct",
            "servicecatalog:DescribeRecord",
            "servicecatalog:SearchProvisionedProducts",
            "servicecatalog:DescribeProduct",
            "servicecatalog:DescribeProductView",
            "servicecatalog:ListLaunchPaths"),
        /**
         * Lets a cross-account connection's role verify its own effective permissions via {@code
         * iam:SimulatePrincipalPolicy} — this is how {@code AccountsController}'s "Validate
         * connection" surfaces a real least-privilege report (which of
         * {@code CrossAccountRoleTemplateFactory}'s granted actions
         * actually evaluate to Allow) instead of just proving {@code sts:AssumeRole} works.
         * Simulate-only — never executes anything, so this is safe to grant broadly. Connections
         * whose role predates this capability simply report "unable to verify" rather than
         * failing validation outright; see {@code StsAssumeRoleService#checkPermissions}.
         */
        SELF_PERMISSION_CHECK("iam:SimulatePrincipalPolicy");

        private final List<String> iamActions;

        Capability(String... iamActions) {
            this.iamActions = List.of(iamActions);
        }

        public List<String> iamActions() {
            return iamActions;
        }
    }

    /** Baseline operator capabilities — inventory/delete + RDS/ECS operator paths. */
    public static Set<Capability> operatorBaseline() {
        return EnumSet.of(
            Capability.CFN_INVENTORY,
            Capability.CFN_DELETE,
            Capability.ECS_DESCRIBE,
            Capability.ECS_UPDATE_SERVICE,
            Capability.RDS_DESCRIBE,
            Capability.RDS_SNAPSHOT,
            Capability.RDS_RESTORE,
            Capability.RDS_ENGINE_UPGRADE,
            Capability.COGNITO_USER_MANAGEMENT);
    }

    /**
     * Direct-deploy capabilities — never included in {@link #operatorBaseline()}, always
     * attached separately (and conditionally, per-capability) by {@code ManagerOperatorIamSupport}
     * only when the deploying caller actually holds the matching {@code deploy:*}
     * {@code ManagerPolicyCatalog} policy.
     */
    public static Set<Capability> deployCapabilities() {
        return EnumSet.of(Capability.CFN_DEPLOY, Capability.SC_PROVISION);
    }

    public static Set<String> iamActions(Iterable<Capability> capabilities) {
        Set<String> actions = new LinkedHashSet<>();
        for (Capability capability : capabilities) {
            actions.addAll(capability.iamActions());
        }
        return Set.copyOf(actions);
    }

    public static Set<String> operatorBaselineIamActions() {
        return iamActions(operatorBaseline());
    }

    public static Capability parse(String id) {
        return Capability.valueOf(id.trim().toUpperCase(Locale.ROOT));
    }

    public static boolean isKnownIamAction(String action) {
        if (action == null || action.isBlank()) {
            return false;
        }
        String normalized = action.trim().toLowerCase(Locale.ROOT);
        return Arrays.stream(Capability.values())
            .flatMap(cap -> cap.iamActions().stream())
            .map(a -> a.toLowerCase(Locale.ROOT))
            .anyMatch(a -> a.equals(normalized));
    }
}