# 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 ||= @deploy.getResourceName(@config["name"]) 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: @config['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'] MU.log "Creating IAM role #{@mu_name}" @cloud_id = @mu_name path = @config['strip_path'] ? nil : "/"+@deploy.deploy_id+"/" MU::Cloud::AWS.iam(credentials: @config['credentials']).create_role( path: path, role_name: @mu_name, description: "Generated by Mu", assume_role_policy_document: gen_assume_role_policy_doc, tags: get_tag_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: @config['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: @config['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'] 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} #{@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') }) configured_policies.each { |pol| } end if !@config['bare_policies'] attached_policies = MU::Cloud::AWS.iam(credentials: @config['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 MU::Cloud::AWS::Role.purgePolicy(a.policy_arn, @config['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] # @param basename [String] # @param credentials [String] # @param path [String] # @return [Array] 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(URI.decode(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 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}" ) 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) return @cloud_desc_cache if @cloud_desc_cache 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]: The target resource. If +target_type+ isn't specified, this should be a fully-resolved ARN. def injectPolicyTargets(policy, targets) if !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: @config['credentials']).get_policy_version( policy_arn: p.arn, version_id: p.default_version_id ).policy_version doc = JSON.parse URI.decode_www_form_component old.document need_update = false doc["Statement"].each { |s| targets.each { |target| target_string = target if target['type'] 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, ignoremaster: false, credentials: nil, flags: {}) resp = MU::Cloud::AWS.iam(credentials: credentials).list_policies( path_prefix: "/"+MU.deploy_id+"/" ) if resp and resp.policies resp.policies.each { |policy| MU.log "Deleting IAM policy /#{MU.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(MU.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 = MU::Cloud::AWS.iam(credentials: credentials).get_role(role_name: r.role_name) 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 == MU.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]: 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:/) 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] ) if resp and resp.role found[args[:cloud_id]] = resp.role end end rescue ::Aws::IAM::Errors::NoSuchEntity end else marker = nil begin resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).list_roles( marker: marker ) break if !resp or !resp.roles resp.roles.each { |role| found[role.role_name] = role } marker = resp.marker end while marker begin resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).list_policies( scope: "Local", marker: marker ) break if !resp or !resp.policies resp.policies.each { |pol| found[pol.arn] = pol } marker = resp.marker end while marker 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" => @config['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(URI.decode(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(URI.decode(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(URI.decode(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 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]: Existing policy list to append to, if any # @return [Array] def self.doc2MuPolicies(basename, doc, policies = []) policies ||= [] 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: @config['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: @config['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: @config['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?(@config["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: @config['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?(@config["region"]) ? "aws-us-gov" : "aws")+":iam::aws:policy/#{subpaths.shift}/"+policy retry end raise e end } end mypolicies.each { |p| if entitytype == "user" resp = MU::Cloud::AWS.iam(credentials: @config['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: @config['credentials']).attach_user_policy( policy_arn: p.arn, user_name: entityname ) end elsif entitytype == "group" resp = MU::Cloud::AWS.iam(credentials: @config['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: @config['credentials']).attach_group_policy( policy_arn: p.arn, group_name: entityname ) end elsif entitytype == "role" resp = MU::Cloud::AWS.iam(credentials: @config['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: @config['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} #{@config['credentials']}" MU::Cloud::AWS.iam(credentials: @config['credentials']).create_instance_profile( instance_profile_name: @mu_name ) rescue Aws::IAM::Errors::EntityAlreadyExists MU::Cloud::AWS.iam(credentials: @config['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: @config['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]: 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.loadCloudType("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] # @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 # 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'] role['dependencies'] ||= [] role['dependencies'] << { "name" => target['identifier'], "type" => target['type'] } 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]: One or more policy chunks # @param deploy_obj [MU::MommaCat]: Deployment object to use when looking up sibling Mu resources # @return [Array] def self.genPolicyDocument(policies, deploy_obj: nil, bucket_style: false) if policies name = nil doc = { "Version" => "2012-10-17", "Statement" => [] } 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 = grantee["identifier"].match(/^[^\.]+\.amazonaws\.com$/) ? "Service" : "AWS" 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["entity_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