cloudamatic/mu

View on GitHub
modules/mu/providers/aws/role.rb

Summary

Maintainability
D
3 days
Test Coverage
# Copyright:: Copyright (c) 2018 eGlobalTech, Inc., all rights reserved
#
# Licensed under the BSD-3 license (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License in the root of the project or at
#
#     http://egt-labs.com/mu/LICENSE.html
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

module MU
  class Cloud
    class AWS
      # A user as configured in {MU::Config::BasketofKittens::roles}
      class Role < MU::Cloud::Role

        # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us.
        # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat
        def initialize(**args)
          super

          if @cloud_id and (!cloud_desc["role"] or cloud_desc["role"].empty?)
            @config['bare_policies'] = true
            if @config['name'].match(/^arn:/) and cloud_desc['policies'].size == 1
              @config['name'] = cloud_desc['policies'].first.policy_name
            end
          end

          @mu_name ||= @config['scrub_mu_isms'] ? @config['name'] : @deploy.getResourceName(@config["name"], max_length: 64)
        end

        # Called automatically by {MU::Deploy#createResources}
        def create
          if @config['raw_policies']
            @config['raw_policies'].each { |policy|
              policy.values.each { |p|
                p["Version"] ||= "2012-10-17"
              }

              policy_name = @mu_name+"-"+policy.keys.first.upcase
              MU.log "Creating IAM policy #{policy_name}"
              MU::Cloud::AWS.iam(credentials: @credentials).create_policy(
                policy_name: policy_name,
                path: "/"+@deploy.deploy_id+"/",
                policy_document: JSON.generate(policy.values.first),
                description: "Generated from inline policy document for Mu role #{@mu_name}"
              )
            }
          end

          if !@config['bare_policies']
            @cloud_id = @mu_name
            path = @config['strip_path'] ? nil : "/"+@deploy.deploy_id+"/"
            params = {
              :path => path,
              :role_name => @mu_name,
              :description => "Generated by Mu",
              :assume_role_policy_document => gen_assume_role_policy_doc,
              :tags => get_tag_params(@config['scrub_mu_isms'])
            }

            MU.log "Creating IAM role #{@mu_name} (#{@credentials})", details: params
            MU::Cloud::AWS.iam(credentials: @credentials).create_role(params)
          end
        end

        # Called automatically by {MU::Deploy#createResources}
        def groom
          if @config['policies']
            @config['raw_policies'] ||= []

            @config['raw_policies'].concat(convert_policies_to_iam)
          end

          if !@config['bare_policies']
            resp = MU::Cloud::AWS.iam(credentials: @credentials).get_role(
              role_name: @mu_name
            ).role
            ext_tags = resp.tags.map { |t| t.to_h }
            tag_param = get_tag_params(true)
            tag_param.reject! { |t| ext_tags.include?(t) }

            if tag_param.size > 0
              MU.log "Updating tags on IAM role #{@mu_name}", MU::NOTICE, details: tag_param
              MU::Cloud::AWS.iam(credentials: @credentials).tag_role(role_name: @mu_name, tags: tag_param)
            end
          end

          if @config['raw_policies'] or @config['attachable_policies']
            configured_policies = []

            if @config['raw_policies']
              MU.log "Attaching #{@config['raw_policies'].size.to_s} raw #{@config['raw_policies'].size > 1 ? "policies" : "policy"} to role #{@mu_name}", MU::NOTICE
              configured_policies = @config['raw_policies'].map { |p|
                @mu_name+"-"+p.keys.first.upcase
              }
            end

            if @config['attachable_policies']
              MU.log "Attaching #{@config['attachable_policies'].size.to_s} external #{@config['attachable_policies'].size > 1 ? "policies" : "policy"} to role #{@mu_name}", MU::NOTICE
              configured_policies.concat(@config['attachable_policies'].map { |p|
                id = if p.is_a?(MU::Config::Ref)
                  p.cloud_id
                else
                  p = MU::Config::Ref.get(p)
                  p.kitten
                  p.cloud_id
                end
                id.gsub(/.*?\/([^:\/]+)$/, '\1')
              })
            end

            # Purge anything that doesn't belong
            if !@config['bare_policies']
              attached_policies = MU::Cloud::AWS.iam(credentials: @credentials).list_attached_role_policies(
                role_name: @mu_name
              ).attached_policies
              attached_policies.each { |a|
                if !configured_policies.include?(a.policy_name)
                  MU.log "Removing IAM policy #{a.policy_name} from role #{@mu_name}", MU::NOTICE, details: configured_policies
                  MU::Cloud::AWS::Role.purgePolicy(a.policy_arn, @credentials)
                end
              }
            end

            # XXX not sure we're binding these sanely, validate that
            if @config['raw_policies']
              MU::Cloud::AWS::Role.manageRawPolicies(
                @config['raw_policies'],
                basename: @deploy.getResourceName(@config['name']),
                credentials: @credentials
              )
            end
          end

          if !@config['bare_policies'] and
             (@config['raw_policies'] or @config['attachable_policies'])
            bindTo("role", @mu_name)
          end
        end

        # Take some AWS policy documents and turn them into policies
        # @param raw_policies [Array<Hash>]
        # @param basename [String]
        # @param credentials [String]
        # @param path [String]
        # @return [Array<String>]
        def self.manageRawPolicies(raw_policies, basename: "", credentials: nil, path: "/"+MU.deploy_id)
          arns = []
          raw_policies.each { |policy|
            policy.values.each { |p|
              p["Version"] ||= "2012-10-17"
            }

            policy_name = basename+"-"+policy.keys.first.upcase
            arn = "arn:"+(MU::Cloud::AWS.isGovCloud? ? "aws-us-gov" : "aws")+":iam::"+MU::Cloud::AWS.credToAcct(credentials)+":policy#{path}/#{policy_name}"
            resp = begin
              desc = MU::Cloud::AWS.iam(credentials: credentials).get_policy(policy_arn: arn)

              version = MU::Cloud::AWS.iam(credentials: credentials).get_policy_version(
                policy_arn: arn,
                version_id: desc.policy.default_version_id
              )

              ext = JSON.parse(CGI.unescape(version.policy_version.document))
              if ext != policy.values.first
                # Special exception- we don't want to overwrite extra rules
                # in MuSecrets policies, because our siblings might have 
                # (will have) injected those and they should stay.
                if policy.size == 1 and policy["MuSecrets"]
                  if (ext["Statement"][0]["Resource"] & policy["MuSecrets"]["Statement"][0]["Resource"]).sort == policy["MuSecrets"]["Statement"][0]["Resource"].sort
                    next
                  end
                end
                MU.log "Updating IAM policy #{policy_name}", MU::NOTICE, details: policy
                ext.diff(policy.values.first)
                update_policy(arn, policy.values.first)
                MU::Cloud::AWS.iam(credentials: credentials).get_policy(policy_arn: arn)
              else
                desc
              end

            rescue Aws::IAM::Errors::NoSuchEntity
              MU.log "Creating IAM policy #{policy_name}", details: policy.values.first
              desc = MU::Cloud::AWS.iam(credentials: credentials).create_policy(
                policy_name: policy_name,
                path: path+"/",
                policy_document: JSON.generate(policy.values.first),
                description: "Raw policy from #{basename}"
              )
              MU.retrier([Aws::IAM::Errors::NoSuchEntity], loop_if: Proc.new { desc.nil? }) {
                desc = MU::Cloud::AWS.iam(credentials: credentials).get_policy(policy_arn: arn)
              }
              desc
            end
            arns << resp.policy.arn
          }
          arns
        end

        # Canonical Amazon Resource Number for this resource
        # @return [String]
        def arn
          desc = cloud_desc
          if desc["role"]
            if desc['role'].is_a?(Hash)
              desc["role"][:arn] # why though
            else
              desc["role"].arn
            end
          else
            nil
          end
        end

        @cloud_desc_cache = nil
        # Return a hash containing a +role+ element and a +policies+ element,
        # populated with one or both depending on what this resource has
        # defined.
        def cloud_desc(use_cache: true)
          require 'aws-sdk-iam'

          # we might inherit a naive cached description from the base cloud
          # layer; rearrange it to our tastes
          if @cloud_desc_cache.is_a?(::Aws::IAM::Types::Role)
            new_desc = {
              "role" => @cloud_desc_cache
            }
            @cloud_desc_cache = new_desc
          elsif @cloud_desc_cache.is_a?(::Aws::IAM::Types::Policy)
            new_desc = {
              "policies" => [@cloud_desc_cache]
            }
            @cloud_desc_cache = new_desc
          end

          return @cloud_desc_cache if @cloud_desc_cache and !@cloud_desc_cache.empty? and use_cache

          @cloud_desc_cache = {}
          if @config['bare_policies']
            if @cloud_id
              pol_desc = MU::Cloud::AWS::Role.find(credentials: @credentials, cloud_id: @cloud_id).values.first
              if pol_desc
                @cloud_desc_cache['policies'] = [pol_desc]
                return @cloud_desc_cache
              end
            end

            if @deploy and @deploy.deploy_id
              @cloud_desc_cache["policies"] = MU::Cloud::AWS.iam(credentials: @credentials).list_policies(
                path_prefix: "/"+@deploy.deploy_id+"/"
              ).policies
              @cloud_desc_cache["policies"].reject! { |p|
                !p.policy_name.match(/^#{Regexp.quote(@mu_name)}-/)
              }
              # this is quasi-wrong because we can be mulitple cloud is, but
              # we can't really set this type to has_multiples because that's
              # just how managed policies work not anything else, goddammit
              # AWS why can't you just bundle everything in roles
              if @cloud_desc_cache["policies"] and @cloud_desc_cache["policies"].size > 0
                @cloud_id ||= @cloud_desc_cache["policies"].first.arn
              end
            end
          else
            if @cloud_id.match(/^arn:aws(:?-us-gov)?:[^:]*:[^:]*:\d*:policy\//)
              pol_desc = MU::Cloud::AWS::Role.find(credentials: @credentials, cloud_id: @cloud_id).values.first
              if pol_desc
                @cloud_desc_cache['policies'] = [pol_desc]
                return @cloud_desc_cache
              end
            end
begin
            @cloud_desc_cache['role'] = MU::Cloud::AWS::Role.find(credentials: @credentials, cloud_id: @cloud_id).values.first
            @cloud_desc_cache['role'] ||= MU::Cloud::AWS::Role.find(credentials: @credentials, cloud_id: @mu_name).values.first
            MU::Cloud::AWS.iam(credentials: @credentials).list_attached_role_policies(
              role_name: @mu_name
            ).attached_policies.each { |p|
              @cloud_desc_cache["policies"] ||= []
              @cloud_desc_cache["policies"] << MU::Cloud::AWS.iam(credentials: @credentials).get_policy(
                policy_arn: p.policy_arn
              ).policy
            }

            inline = MU::Cloud::AWS.iam(credentials: @credentials).list_role_policies(role_name: @mu_name).policy_names
            inline.each { |pol_name|
              @cloud_desc_cache["policies"] ||= []
              @cloud_desc_cache["policies"] << MU::Cloud::AWS.iam(credentials: @credentials).get_role_policy(
                role_name: @mu_name,
                policy_name: pol_name
              )
            }

rescue ::Aws::IAM::Errors::ValidationError => e
MU.log @cloud_id+" "+@mu_name, MU::WARN, details: e.inspect
end
          end
          @cloud_desc_cache['cloud_id'] ||= @cloud_id

          @cloud_desc_cache
        end

        # Return the metadata for this user cofiguration
        # @return [Hash]
        def notify
          MU.structToHash(cloud_desc)
        end

        # Insert a new target entity into an existing policy. 
        # @param policy [String]: The name of the policy to which we're appending, which must already exist as part of this role resource
        # @param targets [Array<String>]: The target resource. If +target_type+ isn't specified, this should be a fully-resolved ARN.
        def injectPolicyTargets(policy, targets, attach: false)
          if @deploy and !policy.match(/^#{@deploy.deploy_id}/)
            policy = @mu_name+"-"+policy.upcase
          end
          my_policies = cloud_desc(use_cache: false)["policies"]
          my_policies ||= []

          seen_policy = false
          my_policies.each { |p|
            if p.policy_name == policy
              seen_policy = true
              old = MU::Cloud::AWS.iam(credentials: @credentials).get_policy_version(
                policy_arn: p.arn,
                version_id: p.default_version_id
              ).policy_version

              doc = JSON.parse CGI.unescape_www_form_component old.document
              need_update = false

              doc["Statement"].each { |s|
                targets.each { |target|
                  target_string = target

                  if target['type'] and @deploy
                    sibling = @deploy.findLitterMate(
                      name: target["identifier"],
                      type: target["type"]
                    )

                    target_string = sibling.cloudobj.arn
                  elsif target.is_a? Hash
                    target_string = target['identifier']
                  end

                  unless s["Resource"].include? target_string
                    s["Resource"] << target_string
                    need_update = true
                  end
                }
              }

              if need_update
                MU.log "Updating IAM policy #{policy} to grant permissions on #{targets.to_s}", details: doc
                update_policy(p.arn, doc)
              end
            end
          }

          if !seen_policy
            MU.log "Was given new targets for policy #{policy}, but I don't see any such policy attached to role #{@cloud_id}", MU::WARN, details: targets
          end
        end

        # Delete an IAM policy, along with attendant versions and attachments.
        # @param policy_arn [String]: The ARN of the policy to purge
        def self.purgePolicy(policy_arn, credentials)
          attachments = MU::Cloud::AWS.iam(credentials: credentials).list_entities_for_policy(
            policy_arn: policy_arn
          )
          attachments.policy_users.each { |u|
            begin
              MU::Cloud::AWS.iam(credentials: credentials).detach_user_policy(
                user_name: u.user_name,
                policy_arn: policy_arn
              )
            rescue ::Aws::IAM::Errors::NoSuchEntity
            end
          }
          attachments.policy_groups.each { |g|
            begin
              MU::Cloud::AWS.iam(credentials: credentials).detach_group_policy(
                group_name: g.group_name,
                policy_arn: policy_arn
              )
            rescue ::Aws::IAM::Errors::NoSuchEntity
            end
          }
          attachments.policy_roles.each { |r|
            begin
              MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy(
                role_name: r.role_name,
                policy_arn: policy_arn
              )
            rescue ::Aws::IAM::Errors::NoSuchEntity
            end
          }
          versions = MU::Cloud::AWS.iam(credentials: credentials).list_policy_versions(
            policy_arn: policy_arn,
          ).versions
          versions.each { |v|
            next if v.is_default_version
            begin
              MU::Cloud::AWS.iam(credentials: credentials).delete_policy_version(
                policy_arn: policy_arn,
                version_id: v.version_id
              )
            rescue ::Aws::IAM::Errors::NoSuchEntity
            end
          }

          # Delete the policy, unless it's one of the global canned ones owned
          # by AWS
          if !policy_arn.match(/^arn:aws:iam::aws:/)
            begin
              MU::Cloud::AWS.iam(credentials: credentials).delete_policy(
                policy_arn: policy_arn
              )
            rescue ::Aws::IAM::Errors::NoSuchEntity
            end
          end
        end

        # Does this resource type exist as a global (cloud-wide) artifact, or
        # is it localized to a region/zone?
        # @return [Boolean]
        def self.isGlobal?
          true
        end

        # Denote whether this resource implementation is experiment, ready for
        # testing, or ready for production use.
        def self.quality
          MU::Cloud::BETA
        end

        # Remove all roles associated with the currently loaded deployment.
        # @param noop [Boolean]: If true, will only print what would be done
        # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server
        # @return [void]
        def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, credentials: nil, flags: {})

          resp = MU::Cloud::AWS.iam(credentials: credentials).list_policies(
            path_prefix: "/"+deploy_id+"/"
          )
          if resp and resp.policies
            resp.policies.each { |policy|
              MU.log "Deleting IAM policy /#{deploy_id}/#{policy.policy_name}"
              if !noop
                purgePolicy(policy.arn, credentials)
              end
            }
          end

          deleteme = []
          roles = MU::Cloud::AWS::Role.find(credentials: credentials).values
          roles.each { |r|
            next if !r.respond_to?(:role_name)
            if r.path.match(/^\/#{Regexp.quote(deploy_id)}/)
              deleteme << r
              next
            end
            # For some dumb reason, the list output that .find gets doesn't
            # include the tags, so we need to fetch each role individually to
            # check tags. Hardly seems efficient.
            desc = begin
              MU::Cloud::AWS.iam(credentials: credentials).get_role(role_name: r.role_name)
            rescue Aws::IAM::Errors::NoSuchEntity
              next
            end
            if desc.role and desc.role.tags and desc.role.tags
              master_match = false
              deploy_match = false
              desc.role.tags.each { |t|
                if t.key == "MU-ID" and t.value == deploy_id
                  deploy_match = true
                elsif t.key == "MU-MASTER-IP" and t.value == MU.mu_public_ip
                  master_match = true
                end
              }
              if deploy_match and (master_match or ignoremaster)
                deleteme << r
              end
            end
          }

          if flags and flags["known"]
            roles = MU::Cloud::AWS::Role.find(credentials: credentials).values
            roles.each { |r|
              next if !r.respond_to?(:role_name)
              deleteme << r if flags["known"].include?(r.role_name)
            }
            deleteme.uniq!
          end
          deleteme.reject! { |r| r.class.name == "Aws::IAM::Types::Policy" }

          if deleteme.size > 0
            deleteme.each { |r|
              MU.log "Deleting IAM role #{r.role_name}"
              if !noop
                # purgePolicy won't touch roles we don't own, so gently detach
                # those first
                detachables = MU::Cloud::AWS.iam(credentials: credentials).list_attached_role_policies(
                  role_name: r.role_name
                ).attached_policies
                detachables.each { |rp|
                  MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy(
                    role_name: r.role_name,
                    policy_arn: rp.policy_arn
                  )
                }

                begin
                  MU::Cloud::AWS.iam(credentials: credentials).remove_role_from_instance_profile(
                    instance_profile_name: r.role_name,
                    role_name: r.role_name
                  )
                  MU::Cloud::AWS.iam(credentials: credentials).delete_instance_profile(instance_profile_name: r.role_name)
                rescue Aws::IAM::Errors::ValidationError => e
                  MU.log "Cleaning up IAM role #{r.role_name}: #{e.inspect}", MU::WARN
                rescue Aws::IAM::Errors::NoSuchEntity
                end

                MU::Cloud::AWS.iam(credentials: credentials).delete_role(
                  role_name: r.role_name
                )
              end
            }
          end

        end

        # Locate an existing user group.
        # @return [Hash<String,OpenStruct>]: The cloud provider's complete descriptions of matching user group.
        def self.find(**args)
          found = {}

          if args[:cloud_id]

            begin
              # managed policies get fetched by ARN, roles by plain name. Ok!
              if args[:cloud_id].match(/^arn:.*?:policy\//)
                resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).get_policy(
                  policy_arn: args[:cloud_id]
                )
                if resp and resp.policy
                  found[args[:cloud_id]] = resp.policy
                end
              else
                resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).get_role(
                  role_name: args[:cloud_id].sub(/^arn:.*?\/([^:\/]+)$/, '\1') # XXX if it's an ARN, actually parse it and look in the correct account when applicable
                )

                if resp and resp.role
                  found[resp.role.role_name] = resp.role
                end
              end
            rescue ::Aws::IAM::Errors::NoSuchEntity
            end

          else
            resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).list_roles
            resp.roles.each { |role|
              found[role.role_name] = role
            }

            resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).list_policies(scope: "Local")
            resp.policies.each { |pol|
              found[pol.arn] = pol
            }
          end

          found
        end

        # Reverse-map our cloud description into a runnable config hash.
        # We assume that any values we have in +@config+ are placeholders, and
        # calculate our own accordingly based on what's live in the cloud.
        def toKitten(**_args)
          bok = {
            "cloud" => "AWS",
            "credentials" => @credentials,
            "cloud_id" => @cloud_id
          }

          if !cloud_desc or (!@config['bare_policies'] and !cloud_desc['role'])
            MU.log "toKitten failed to load a cloud_desc from #{@cloud_id}", MU::ERR, details: @config
            return nil
          end

          desc = cloud_desc['role']
          if desc
            bok["name"] = desc.role_name
          else
            desc = cloud_desc['policies']
          end

          policies = cloud_desc['policies']
          if policies and policies.size > 0
            if @config['bare_policies']
              bok['name'] = policies.first.policy_name
              bok['bare_policies'] = true
            end

            policies.each { |pol|
              if pol.respond_to?(:arn) and
                 pol.arn.match(/^arn:aws(?:-us-gov)?:iam::aws:policy\/.*?([^\/]+)$/)
                next # we'll get these later
              else
                doc = begin
                  resp = MU::Cloud::AWS.iam(credentials: @credentials).get_role_policy(
                    role_name: @cloud_id,
                    policy_name: pol.policy_name
                  )
                  if resp and resp.policy_document
                    JSON.parse(CGI.unescape(resp.policy_document))
                  end
                rescue ::Aws::IAM::Errors::NoSuchEntity, ::Aws::IAM::Errors::ValidationError
                  resp = MU::Cloud::AWS.iam(credentials: @credentials).get_policy(
                    policy_arn: pol.arn
                  )
                  version = MU::Cloud::AWS.iam(credentials: @credentials).get_policy_version(
                    policy_arn: pol.arn,
                    version_id: resp.policy.default_version_id
                  )
                  JSON.parse(CGI.unescape(version.policy_version.document))
                end
                bok["policies"] = MU::Cloud::AWS::Role.doc2MuPolicies(pol.policy_name, doc, bok["policies"])
              end
            }

            return bok if @config['bare_policies']
          end

          if desc.tags and desc.tags.size > 0
            bok["tags"] = MU.structToHash(desc.tags, stringify_keys: true)
          end

          bok["strip_path"] = true if desc.path == "/"

          if desc.assume_role_policy_document
            assume_doc = JSON.parse(CGI.unescape(desc.assume_role_policy_document))
            assume_doc["Statement"].each { |s|
              bok["can_assume"] ||= []
              method = if s["Action"] == "sts:AssumeRoleWithWebIdentity"
                "web"
              elsif s["Action"] == "sts:AssumeRoleWithSAML"
                "saml"
              else
                "basic"
              end
              assume_block = {
                "assume_method" => method
              }
              if s["Condition"]
                assume_block["conditions"] ||= []
                s["Condition"].each_pair { |comparison, relation|
                  relation.each_pair { |variable, values|
                    values = [values] if !values.is_a?(Array)
                    assume_block["conditions"] << {
                      "comparison" => comparison,
                      "variable" => variable,
                      "values" => values
                    }
                  }
                }
              end
              s["Principal"].each_pair { |type, principals|
                my_assume = assume_block.merge({ "entity_type" => type.downcase })
                if principals.is_a?(String)
                  bok["can_assume"] << my_assume.merge({
                    "entity_id" => principals
                  })
                else
                  principals.each { |p|
                    bok["can_assume"] << my_assume.merge({
                      "entity_id" => p
                    })
                  }
                end
              }
            }
          end

          # Grab and reference any managed policies attached to this role
          resp = MU::Cloud::AWS.iam(credentials: @credentials).list_attached_role_policies(role_name: @cloud_id)
          if resp and resp.attached_policies
            resp.attached_policies.each { |pol|
              bok["attachable_policies"] ||= []
              if pol.policy_arn.match(/arn:aws(?:-us-gov)?:iam::aws:policy\//)
                bok["attachable_policies"] << MU::Config::Ref.get(
                  id: pol.policy_name,
                  cloud: "AWS"
                )
              else
                bok["attachable_policies"] << MU::Config::Ref.get(
                  id: pol.policy_arn,
                  name: pol.policy_name,
                  cloud: "AWS",
                  type: "roles"
                )
              end
            }
          end

          bok["attachable_policies"].uniq! if bok["attachable_policies"]
          bok["name"].gsub!(/[^a-zA-Z0-9_\-]/, "_")

          bok
        end

        # Convert an IAM policy document to our own shorthand Basket of Kittens
        # schema.
        # @param doc [Hash]: The decoded IAM policy document
        # @param policies [Array<Hash>]: Existing policy list to append to, if any
        # @return [Array<Hash>]
        def self.doc2MuPolicies(basename, doc, policies = [])
          policies ||= []

          if !doc["Statement"].is_a?(Array)
            doc["Statement"] = [doc["Statement"]]
          end

          doc["Statement"].each { |s|
            if !s["Action"]
              MU.log "Statement in policy document for #{basename} didn't have an Action field", MU::WARN, details: doc
              next
            end
            s["Resource"] = [s["Resource"]] if s["Resource"].is_a?(String)
            s["Action"] = [s["Action"]] if s["Action"].is_a?(String)
            conditions = nil
            if s["Condition"]
              conditions = []
              s["Condition"].each_pair { |comparison, relation|
                relation.each_pair { |variable, values|
                  values = [values] if !values.is_a?(Array)
                  conditions << {
                    "comparison" => comparison,
                    "variable" => variable,
                    "values" => values
                  }
                }
              }
            end
            policy = {
              "name" => basename + (doc["Statement"].size > 1 ? "_"+policies.size.to_s : ""),
              "permissions" => s["Action"],
              "flag" => s["Effect"].downcase,
              "targets" => s["Resource"].map { |r|
                if r.match(/^arn:aws(-us-gov)?:([^:]+):.*?:([^:]*)$/)
# XXX which cases even count for blind references to sibling resources?
                  if Regexp.last_match[1] == "s3"
                    "bucket"
                  elsif Regexp.last_match[1]
                    MU.log "Service #{Regexp.last_match[1]} to type...", MU::WARN, details: r
                    nil
                  end
                end
                {
                  "identifier" => r
                }
              }
            }
            policy["conditions"] = conditions if conditions
            policies << policy
          }
          policies
        end

        # Attach this role or group of loose policies to the specified entity.
        # @param entitytype [String]: The type of entity (user, group or role for policies; instance_profile for roles)
        def bindTo(entitytype, entityname)
          if entitytype == "instance_profile"
            begin
              resp = MU::Cloud::AWS.iam(credentials: @credentials).get_instance_profile(
                instance_profile_name: entityname
              ).instance_profile

              if !resp.roles.map { |r| r.role_name}.include?(@mu_name)
                MU::Cloud::AWS.iam(credentials: @credentials).add_role_to_instance_profile(
                  instance_profile_name: entityname,
                  role_name: @mu_name
                )
              end
            rescue StandardError => e
              MU.log "Error binding role #{@mu_name} to instance profile #{entityname}: #{e.message}", MU::ERR
              raise e
            end
          elsif ["user", "group", "role"].include?(entitytype)
            mypolicies = MU::Cloud::AWS.iam(credentials: @credentials).list_policies(
              path_prefix: "/"+@deploy.deploy_id+"/"
            ).policies
            mypolicies.reject! { |p|
              !p.policy_name.match(/^#{Regexp.quote(@mu_name)}(-|$)/)
            }

            if @config['attachable_policies']
              @config['attachable_policies'].each { |policy_hash|
                policy = policy_hash["id"]
                p_arn = if !policy.match(/^arn:/i)
                  "arn:"+(MU::Cloud::AWS.isGovCloud?(@region) ? "aws-us-gov" : "aws")+":iam::aws:policy/"+policy
                else
                  policy
                end

                subpaths = ["service-role", "aws-service-role", "job-function"]
                begin
                  mypolicies << MU::Cloud::AWS.iam(credentials: @credentials).get_policy(
                    policy_arn: p_arn
                  ).policy
                rescue Aws::IAM::Errors::NoSuchEntity => e
                  if subpaths.size > 0
                    p_arn = "arn:"+(MU::Cloud::AWS.isGovCloud?(@region) ? "aws-us-gov" : "aws")+":iam::aws:policy/#{subpaths.shift}/"+policy
                    retry
                  end
                  raise e
                end
              }
            end

            if @config['raw_policies']
              raw_arns = MU::Cloud::AWS::Role.manageRawPolicies(
                @config['raw_policies'],
                basename: @deploy.getResourceName(@config['name']),
                credentials: @credentials
              )
              raw_arns.each { |p_arn|
                mypolicies << MU::Cloud::AWS.iam(credentials: @credentials).get_policy(
                  policy_arn: p_arn
                ).policy
              }
            end

            mypolicies.each { |p|
              if entitytype == "user"
                resp = MU::Cloud::AWS.iam(credentials: @credentials).list_attached_user_policies(
                  path_prefix: "/"+@deploy.deploy_id+"/",
                  user_name: entityname
                )
                if !resp or !resp.attached_policies.map { |a_p| a_p.policy_name }.include?(p.policy_name)
                  MU.log "Attaching IAM policy #{p.policy_name} to user #{entityname}", MU::NOTICE
                  MU::Cloud::AWS.iam(credentials: @credentials).attach_user_policy(
                    policy_arn: p.arn,
                    user_name: entityname
                  )
                end
              elsif entitytype == "group"
                resp = MU::Cloud::AWS.iam(credentials: @credentials).list_attached_group_policies(
                  path_prefix: "/"+@deploy.deploy_id+"/",
                  group_name: entityname
                )
                if !resp or !resp.attached_policies.map { |a_p| a_p.policy_name }.include?(p.policy_name)
                  MU.log "Attaching policy #{p.policy_name} to group #{entityname}", MU::NOTICE
                  MU::Cloud::AWS.iam(credentials: @credentials).attach_group_policy(
                    policy_arn: p.arn,
                    group_name: entityname
                  )
                end
              elsif entitytype == "role"
                resp = MU::Cloud::AWS.iam(credentials: @credentials).list_attached_role_policies(
                  role_name: entityname
                )

                if !resp or !resp.attached_policies.map { |a_p| a_p.policy_name }.include?(p.policy_name)
                  MU.log "Attaching policy #{p.policy_name} to role #{entityname}", MU::NOTICE
                  MU::Cloud::AWS.iam(credentials: @credentials).attach_role_policy(
                    policy_arn: p.arn,
                    role_name: entityname
                  )
                end
              end
            }
          else
            raise MuError, "Invalid entitytype '#{entitytype}' passed to MU::Cloud::AWS::Role.bindTo. Must be be one of: user, group, role, instance_profile"
          end
          cloud_desc(use_cache: false)
        end

        # Create an instance profile for EC2 instances, named identically and
        # bound to this role.
        def createInstanceProfile
          if @config['bare_policies']
            raise MuError, "#{self} has 'bare_policies' set, cannot create an instance profile without a role to bind"
          end

          resp = begin
            MU.log "Creating instance profile #{@mu_name} #{@credentials}"
            MU::Cloud::AWS.iam(credentials: @credentials).create_instance_profile(
              instance_profile_name: @mu_name
            )
          rescue Aws::IAM::Errors::EntityAlreadyExists
            MU::Cloud::AWS.iam(credentials: @credentials).get_instance_profile(
              instance_profile_name: @mu_name
            )
          end

          # make sure it's really there before moving on
          begin
            MU::Cloud::AWS.iam(credentials: @credentials).get_instance_profile(instance_profile_name: @mu_name)
          rescue Aws::IAM::Errors::NoSuchEntity => e
            MU.log e.inspect, MU::WARN
            sleep 10
            retry
          end

          bindTo("instance_profile", @mu_name)

          resp.instance_profile.arn
        end

        # Schema fragment for IAM policy conditions, which some other resource
        # types may need to import.
        def self.condition_schema
          {
            "type" => "array",
            "items" => {
              "properties" => {
                "conditions" => {
                  "type" => "array",
                  "items" => {
                    "type" => "object",
                    "description" => "One or more conditions under which to apply this policy. See also: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html",
                    "required" => ["comparison", "variable", "values"],
                    "properties" => {
                      "comparison" => {
                        "type" => "string",
                        "description" => "A comparison to make, like +DateGreaterThan+ or +IpAddress+."
                      },
                      "variable" => {
                        "type" => "string",
                        "description" => "The variable which we will compare, like +aws:CurrentTime+ or +aws:SourceIp+."
                      },
                      "values" => {
                        "type" => "array",
                        "items" => {
                          "type" => "string",
                          "description" => "Value(s) to which we will compare our variable, like +2013-08-16T15:00:00Z+ or +192.0.2.0/24+."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        end

        # Cloud-specific configuration properties.
        # @param _config [MU::Config]: The calling MU::Config object
        # @return [Array<Array,Hash>]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource
        def self.schema(_config)
          toplevel_required = []
          aws_resource_types = MU::Cloud.resource_types.keys.reject { |t|
            begin
              MU::Cloud.resourceClass("AWS", t)
              false
            rescue MuCloudResourceNotImplemented
              true
            end
          }.map { |t| MU::Cloud.resource_types[t][:cfg_name] }.sort

          schema = {
            "tags" => MU::Config.tags_primitive,
            "optional_tags" => MU::Config.optional_tags_primitive,
            "policies" => MU::Cloud::AWS::Role.condition_schema,
            "import" => {
              "type" => "array",
              "items" => {
                "type" => "string",
                "description" => "DEPRECATED, use +attachable_policies+ instead. A shorthand reference to a canned IAM policy like +AdministratorAccess+, a full ARN like +arn:aws:iam::aws:policy/AmazonESCognitoAccess+."
              }
            },
            "attachable_policies" => {
              "type" => "array",
              "items" => MU::Config::Ref.schema(type: "roles", desc: "Reference to a managed policy, which can either refer to an existing managed policy or a sibling {MU::Config::BasketofKittens::roles} object which has +bare_policies+ set.", omit_fields: ["region", "tag"])
            },
            "strip_path" => {
              "type" => "boolean",
              "default" => false,
              "description" => "Normally we namespace IAM roles with a +path+ set to match our +deploy_id+; this disables that behavior. Temporary workaround for a bug in EKS/IAM integration."
            },
            "bare_policies" => {
              "type" => "boolean",
              "default" => false,
              "description" => "Do not create a role, but simply create the policies specified in +policies+ and/or +iam_policies+ for direct attachment to other entities."
            },
            "can_assume" => {
              "type" => "array",
              "items" => {
                "type" => "object",
                "description" => "Entities which are permitted to assume this role. Can be services, IAM objects, or other Mu resources.",
                "required" => ["entity_type", "entity_id"],
                "properties" => {
                  "conditions" => MU::Cloud::AWS::Role.condition_schema["items"]["properties"]["conditions"],
                  "entity_type" => {
                    "type" => "string",
                    "description" => "Type of entity which will be permitted to assume this role. See +entity_id+ for details.",
                    "enum" => ["service", "aws", "federated"]+aws_resource_types
                  },
                  "assume_method" => {
                    "type" => "string",
                    "description" => "https://docs.aws.amazon.com/STS/latest/APIReference/API_Operations.html",
                    "enum" => ["basic", "saml", "web"],
                    "default" => "basic"
                  },
                  "entity_id" => {
                    "type" => "string",
                    "description" => "An identifier appropriate for the +entity_type+ which is allowed to assume this role- see details for valid formats.\n
**service**: The name of a service which is allowed to assume this role, such as +ec2.amazonaws.com+. See also https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-service.html#roles-creatingrole-service-api. For an unofficial list of service names, see https://gist.github.com/shortjared/4c1e3fe52bdfa47522cfe5b41e5d6f22\n
**#{aws_resource_types.join(", ")}**: A resource of one of these Mu types, declared elsewhere in this stack with a name specified in +entity_id+, for which Mu will attempt to resolve the appropriate *aws* or *service* identifier.\n
**aws**: An ARN which should be permitted to assume this role, often another role like +arn:aws:iam::AWS-account-ID:role/role-name+ or a specific user session such as +arn:aws:sts::AWS-account-ID:assumed-role/role-name/role-session-name+. See also https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html#Principal_specifying\n
**federated**: A federated identity provider, such as +accounts.google.com+ or +arn:aws:iam::AWS-account-ID:saml-provider/provider-name+. See also https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html#Principal_specifying"
                  },
# XXX it's possible that 'role' is the only Mu resource type that maps to something that can assume another role in AWS IAM, so maybe that aws_resource_types.join should be something simpler
                }
              }
            },
            "raw_policies" => {
              "type" => "array",
              "items" => {
                "type" => "object",
                "description" => "Amazon-compatible policy documents, as YAML objects if your Basket of Kittens is written YAML, or JSON objects if in JSON. Note that +policies+ is considerably easier to use, and is recommended. For more on the raw AWS policy format, see https://docs.aws.amazon.com/IAM/latest/RoleGuide/access_policies_examples.html for example policies.",
              }
            },
            "iam_policies" => {
              "type" => "array",
              "items" => {
                "type" => "object",
                "description" => "DEPRECATED, use +raw_policies+ or +policies+ instead."
              }
            }
          }
          [toplevel_required, schema]
        end

        # Verify that managed policies from +attachable_policies+ actually
        # exist.
        # @param attachables [Array<Hash>]
        # @param credentials [String]
        # @param region [String]
        def self.validateAttachablePolicies(attachables, credentials: nil, region: MU.curRegion)
          ok = true
          return ok if !attachables

          attachables.each { |ref|
            next if !ref["id"]
# XXX search our account too
            arn = if !ref["id"].match(/^arn:/i)
              "arn:"+(MU::Cloud::AWS.isGovCloud?(region) ? "aws-us-gov" : "aws")+":iam::aws:policy/"+ref["id"]
            else
              ref["id"]
            end
            subpaths = ["service-role", "aws-service-role", "job-function"]
            begin
              MU::Cloud::AWS.iam(credentials: credentials).get_policy(policy_arn: arn)
            rescue Aws::IAM::Errors::NoSuchEntity
              if subpaths.size > 0
                arn = "arn:"+(MU::Cloud::AWS.isGovCloud?(region) ? "aws-us-gov" : "aws")+":iam::aws:policy/#{subpaths.shift}/"+ref["id"]
                retry
              end
              MU.log "No such canned AWS IAM policy '#{arn}'", MU::ERR
              ok = false
            end
            ref["id"] = arn
          }

          ok
        end

        # Cloud-specific pre-processing of {MU::Config::BasketofKittens::roles}, bare and unvalidated.
        # @param role [Hash]: The resource to process and validate
        # @param _configurator [MU::Config]: The overall deployment configurator of which this resource is a member
        # @return [Boolean]: True if validation succeeded, False otherwise
        def self.validateConfig(role, _configurator)
          ok = true

          # munge things declared with the deprecated import keyword into
          # attachable_policies where they belong
          if role['import']
            role['attachable_policies'] ||= []
            role['import'].each { |policy|
              role['attachable_policies'] << { "id" => policy }
            }
            role.delete("import")
          end

          role['strip_path'] = true if role['scrub_mu_isms']

          # If we're attaching some managed policies, make sure all of the ones
          # that should already exist do indeed exist
          if role['attachable_policies']
            ok = false if !self.validateAttachablePolicies(
              role['attachable_policies'],
              credentials: role['credentials'],
              region: role['region']
            )
          end

          if role['iam_policies'] and !role['iam_policies'].empty?
            role['raw_policies'] = Marshal.load(Marshal.dump(role['iam_policies']))
            role.delete('iam_policies')
          end

          if role["bare_policies"] and (!role["raw_policies"] or role["raw_policies"].empty?) and (!role["policies"] or role["policies"].empty?)
            MU.log "IAM role #{role['name']} has bare_policies set, but no policies or raw_policies were specified", MU::ERR
            ok = false
          end

          if (!role['can_assume'] or role['can_assume'].empty?) and
             !role["bare_policies"]
            MU.log "IAM role #{role['name']} must specify at least one can_assume entry", MU::ERR
            ok = false
          end

          if role['policies']
            role['policies'].each { |policy|
              policy['targets'].each { |target|
                if target['type']
                  MU::Config.addDependency(role, target['identifier'], target['type'], my_phase: "groom")
                end
              }
            }
          end

          ok
        end

        # Convert our generic internal representation of access policies into
        # structures suitable for AWS IAM policy documents. Let's return a
        # single policy with a bunch of statements, though, instead of a
        # shedload of individual policies.
        # @param policies [Array<Hash>]: One or more policy chunks
        # @param deploy_obj [MU::MommaCat]: Deployment object to use when looking up sibling Mu resources
        # @return [Array<Hash>]
        def self.genPolicyDocument(policies, deploy_obj: nil, bucket_style: false, version: "2012-10-17", doc_id: nil)
          if policies
            name = nil
            doc = {
              "Version" => version,
              "Statement" => []
            }
            doc["Id"] = doc_id if doc_id
            policies.each { |policy|
              policy["flag"] ||= "Allow"
              statement = {
                "Sid" => policy["name"].gsub(/[^0-9A-Za-z]*/, ""),
                "Effect" => policy['flag'].capitalize,
                "Action" => [],
                "Resource" => []
              }
              name ||= statement["Sid"]
              policy["permissions"].each { |perm|
                statement["Action"] << perm
              }
              if policy["conditions"]
                statement["Condition"] ||= {}
                policy["conditions"].each { |cond|
                  statement["Condition"][cond['comparison']] = {
                    cond["variable"] => cond["values"]
                  }
                }
              end
              if policy["grant_to"] # XXX factor this with target, they're too similar
                statement["Principal"] ||= []
                policy["grant_to"].each { |grantee|
                  grantee["identifier"] ||= grantee["id"]
                  if grantee["type"] and deploy_obj
                    sibling = deploy_obj.findLitterMate(
                      name: grantee["identifier"],
                      type: grantee["type"]
                    )
                    if sibling
                      id = sibling.cloudobj.arn
                      if bucket_style
                        statement["Principal"] << { "AWS" => id }
                      else
                        statement["Principal"] << id
                      end
                    else
                      raise MuError, "Couldn't find a #{grantee["type"]} named #{grantee["identifier"]} when generating IAM policy"
                    end
                  else
                    bucket_prefix = if grantee["identifier"].match(/^[^\.]+\.amazonaws\.com$/)
                      "Service"
                    elsif grantee["identifier"] =~ /^[a-f0-9]+$/
                      "CanonicalUser"
                    else
                      "AWS"
                    end

                    if bucket_style
                      statement["Principal"] << { bucket_prefix => grantee["identifier"] }
                    else
                      statement["Principal"] << grantee["identifier"]
                    end
                  end
                }
                if policy["grant_to"].size == 1
                  statement["Principal"] = statement["Principal"].first
                end
              end
              if policy["targets"]
                policy["targets"].each { |target|
                  target["identifier"] ||= target["id"]
                  if target["type"] and deploy_obj
                    sibling = deploy_obj.findLitterMate(
                      name: target["identifier"],
                      type: target["type"]
                    )
                    if sibling
                      id = sibling.cloudobj.arn
                      id.sub!(/:([^:]+)$/, ":"+'\1'+target["path"]) if target["path"]
                      statement["Resource"] << id
                      if id.match(/:log-group:/)
                        stream_id = id.sub(/:([^:]+)$/, ":log-stream:*")
#                        "arn:aws:logs:us-east-2:accountID:log-group:log_group_name:log-stream:CloudTrail_log_stream_name_prefix*"
                        statement["Resource"] << stream_id
                      elsif id.match(/:s3:/)
                        statement["Resource"] << id+"/*"
                      end
                    else
                      raise MuError, "Couldn't find a #{target["type"]} named #{target["identifier"]} when generating IAM policy"
                    end
                  else
                    target["identifier"] += target["path"] if target["path"]
                    statement["Resource"] << target["identifier"]
                  end
                }
              end
              doc["Statement"] << statement
            }
            return [ { name => doc} ]
          end

          []
        end

        # Update a policy, handling deletion of old versions as needed
        # @param arn [String]:
        # @param doc [Hash]:
        # @param credentials [String]:
        def self.update_policy(arn, doc, credentials: nil)
# XXX this is just blindly replacing identical versions, when it should check
# and guard
          begin
            MU::Cloud::AWS.iam(credentials: credentials).create_policy_version(
              policy_arn: arn,
              set_as_default: true,
              policy_document: JSON.generate(doc)
            )
          rescue Aws::IAM::Errors::LimitExceeded
            delete_version = MU::Cloud::AWS.iam(credentials: credentials).list_policy_versions(
              policy_arn: arn,
            ).versions.last.version_id
            MU.log "Purging oldest version (#{delete_version}) of IAM policy #{arn}", MU::NOTICE
            MU::Cloud::AWS.iam(credentials: credentials).delete_policy_version(
              policy_arn: arn,
              version_id: delete_version
            )
            retry
          end
        end

        private

        # Convert entries from the cloud-neutral @config['policies'] list into
        # AWS syntax.
        def convert_policies_to_iam
          MU::Cloud::AWS::Role.genPolicyDocument(@config['policies'], deploy_obj: @deploy)
        end

        def get_tag_params(strip_std = false)
          @config['tags'] ||= []

          if !strip_std
            MU::MommaCat.listStandardTags.each_pair { |key, value|
              @config['tags'] << { "key" => key, "value" => value }
            }

            if @config['optional_tags']
              MU::MommaCat.listOptionalTags.each { |key, value|
                @config['tags'] << { "key" => key, "value" => value }
              }
            end
          end

          @config['tags'].map { |t|
            { :key => t["key"], :value => t["value"] }
          }
        end

        def gen_assume_role_policy_doc
          role_policy_doc = {
            "Version" => "2012-10-17",
          }

          statements = []
          if @config['can_assume']
            act_map = {
              "basic" => "sts:AssumeRole",
              "saml" => "sts:AssumeRoleWithSAML",
              "web" => "sts:AssumeRoleWithWebIdentity"
            }
            @config['can_assume'].each { |svc|
              statement = {
                "Effect" => "Allow",
                "Action" => act_map[svc['assume_method']],
                "Principal" => {}
              }
              if svc["conditions"]
                statement["Condition"] ||= {}
                svc["conditions"].each { |cond|
                  statement["Condition"][cond['comparison']] = {
                    cond["variable"] => cond["values"]
                  }
                }
              end
              if ["service", "iam", "federated"].include?(svc["entity_type"])
                statement["Principal"][svc["entity_type"].capitalize] = svc["entity_id"]
              else
                sibling = @deploy.findLitterMate(
                  name: svc["entity_id"],
                  type: svc["entity_type"]
                )
                if sibling
                  statement["Principal"][svc["entity_type"].capitalize] = sibling.cloudobj.arn
                else
                  raise MuError, "Couldn't find a #{svc["entity_type"]} named #{svc["entity_id"]} when generating IAM policy in role #{@mu_name}"
                end
              end
              statements << statement
            }
          end

          role_policy_doc["Statement"] = statements

          JSON.generate(role_policy_doc)
        end

        # Update a policy, handling deletion of old versions as needed
        def update_policy(arn, doc)
          MU::Cloud::AWS::Role.update_policy(arn, doc, credentials: @credentials)
        end


      end
    end
  end
end