modules/mu/providers/aws/role.rb
# 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