# Copyright:: Copyright (c) 2019 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 Google
# A role 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
@mu_name ||= if !@config['scrub_mu_isms']
@deploy.getResourceName(@config["name"])
else
@config['name']
end
# If we're being reverse-engineered from a cloud descriptor, use that
# to determine what sort of account we are.
if args[:from_cloud_desc]
require 'google/apis/admin_directory_v1'
@cloud_desc_cache = args[:from_cloud_desc]
if args[:from_cloud_desc].class == ::Google::Apis::AdminDirectoryV1::Role
@config['role_source'] = "directory"
elsif args[:from_cloud_desc].name.match(/^roles\/(.*)/) or
(@cloud_id and @cloud_id.match(/^roles\/(.*)/))
@config['role_source'] = "canned"
@config['name'] = Regexp.last_match[1]
elsif args[:from_cloud_desc].name.match(/^organizations\/\d+\/roles\/(.*)/) or
(@cloud_id and @cloud_id.match(/^organizations\/\d+\/roles\/(.*)/))
@config['role_source'] = "org"
@config['name'] = Regexp.last_match[1]
elsif args[:from_cloud_desc].name.match(/^projects\/([^\/]+?)\/roles\/(.*)/) or
(@cloud_id and @cloud_id.match(/^projects\/\d+\/roles\/(.*)/))
@config['project'] = Regexp.last_match[1]
@config['name'] = Regexp.last_match[2]
@project_id = @config['project']
@config['role_source'] = "project"
else
MU.log "I don't know what to do with this #{args[:from_cloud_desc].class.name}", MU::ERR, details: args[:from_cloud_desc]
raise MuError, "I don't know what to do with this #{args[:from_cloud_desc].class.name}"
end
end
end
# Called automatically by {MU::Deploy#createResources}
def create
@config['display_name'] ||= @mu_name
if @config['role_source'] == "directory"
role_obj = MU::Cloud::Google.admin_directory(:Role).new(
role_name: @mu_name,
role_description: @config['display_name'],
role_privileges: MU::Cloud::Google::Role.map_directory_privileges(@config['import'], credentials: @credentials).first
)
MU.log "Creating directory role #{@mu_name}", details: role_obj
resp = MU::Cloud::Google.admin_directory(credentials: @credentials).insert_role(@customer, role_obj)
@cloud_id = resp.role_id.to_s
elsif @config['role_source'] == "canned"
@cloud_id = @config['name']
if !@cloud_id.match(/^roles\//)
@cloud_id = "roles/"+@cloud_id
end
else
create_role_obj = MU::Cloud::Google.iam(:CreateRoleRequest).new(
role: MU::Cloud::Google.iam(:Role).new(
title: @config['display_name'],
description: @config['description']
),
role_id: MU::Cloud::Google.nameStr(@deploy.getResourceName(@config["name"], max_length: 64)).gsub(/[^a-zA-Z0-9_\.]/, "_")
)
resp = if @config['role_source'] == "org"
my_org = MU::Cloud::Google.getOrg(@config['credentials'])
MU.log "Creating IAM organization role #{@mu_name} in #{my_org.display_name}", details: create_role_obj
MU::Cloud::Google.iam(credentials: @credentials).create_organization_role(my_org.name, create_role_obj)
elsif @config['role_source'] == "project"
if !@project_id
raise MuError, "Role #{@mu_name} is supposed to be in project #{@config['project']}, but no such project was found"
end
MU.log "Creating IAM project role #{@mu_name} in #{@project_id}", details: create_role_obj
MU::Cloud::Google.iam(credentials: @credentials).create_project_role("projects/"+@project_id, create_role_obj)
end
@cloud_id = resp.name
end
end
# Called automatically by {MU::Deploy#createResources}
def groom
if @config['role_source'] == "directory"
# MU.log "Updating directory role #{@mu_name}", MU::NOTICE, details: role_obj
# MU::Cloud::Google.admin_directory(credentials: @credentials).patch_role(@customer, @cloud_id, role_obj)
elsif @config['role_source'] == "org"
elsif @config['role_source'] == "project"
elsif @config['role_source'] == "canned"
end
@config['bindings'].each { |binding|
binding.keys.each { |scopetype|
next if scopetype == "entity"
binding[scopetype].each { |scope|
# XXX handle entity being a MU::Config::Ref
entity_id = if binding["entity"]["name"]
sib = @deploy.findLitterMate(name: binding["entity"]["name"], type: binding["entity"]["type"])
raise MuError, "Failed to look up sibling #{binding["entity"]["type"]}:#{binding["entity"]["name"]}" if !sib
if binding["entity"]["type"] == "users" and sib.config["type"] == "service"
binding["entity"]["type"] = "serviceAccount"
end
sib.cloud_id
else
binding["entity"]["id"]
end
# XXX resolve scope as well, if it's named or a MU::Config::Ref
bindToIAM(binding["entity"]["type"], entity_id.sub(/.*?\/([^\/]+)$/, '\1'), scopetype, scope["id"])
}
}
}
end
@cloud_desc_cache = nil
# Return the cloud descriptor for the Role
# @return [Google::Apis::Core::Hashable]
def cloud_desc(use_cache: true)
return @cloud_desc_cache if @cloud_desc_cache and use_cache
MU::Cloud::Google.getOrg(@config['credentials'])
@cloud_desc_cache = if @config['role_source'] == "directory"
MU::Cloud::Google.admin_directory(credentials: @config['credentials']).get_role(@customer, @cloud_id)
elsif @config['role_source'] == "canned"
MU::Cloud::Google.iam(credentials: @config['credentials']).get_role(@cloud_id)
elsif @config['role_source'] == "project"
MU::Cloud::Google.iam(credentials: @config['credentials']).get_project_role(@cloud_id)
elsif @config['role_source'] == "org"
MU::Cloud::Google.iam(credentials: @config['credentials']).get_organization_role(@cloud_id)
end
@cloud_desc_cache
end
# Return the metadata for this group configuration
# @return [Hash]
def notify
base = MU.structToHash(cloud_desc)
base.delete(:etag)
base["cloud_id"] = @cloud_id
base
end
# Wrapper for #{MU::Cloud::Google::Role.bindToIAM}
def bindToIAM(entity_type, entity_id, scope_type, scope_id)
MU::Cloud::Google::Role.bindToIAM(@cloud_id, entity_type, entity_id, scope_type, scope_id, credentials: @config['credentials'])
end
@@role_bind_semaphore = Mutex.new
@@role_bind_scope_semaphores = {}
# Attach a role to an entity
# @param role_id [String]: The cloud identifier of the role to which we're binding
# @param entity_type [String]: The kind of entity to bind; typically user, group, or domain
# @param entity_id [String]: The cloud identifier of the entity
# @param scope_type [String]: The kind of scope in which this binding will be valid; typically project, folder, or organization
# @param scope_id [String]: The cloud identifier of the scope in which this binding will be valid
# @param credentials [String]:
def self.bindToIAM(role_id, entity_type, entity_id, scope_type, scope_id, credentials: nil, debug: false)
loglevel = debug ? MU::NOTICE : MU::DEBUG
MU.log "Google::Role.bindToIAM(role_id: #{role_id}, entity_type: #{entity_type}, entity_id: #{entity_id}, scope_type: #{scope_type}, scope_id: #{scope_id}, credentials: #{credentials})", loglevel
# scope_id might actually be the name of a credential set; if so, we
# map it back to an actual organization on the fly
if scope_type == "organizations"
my_org = MU::Cloud::Google.getOrg(scope_id)
if my_org
scope_id = my_org.name
end
end
@@role_bind_semaphore.synchronize {
@@role_bind_scope_semaphores[scope_id] ||= Mutex.new
}
@@role_bind_scope_semaphores[scope_id].synchronize {
entity = entity_type.sub(/s$/, "")+":"+entity_id
policy = if scope_type == "organizations"
MU::Cloud::Google.resource_manager(credentials: credentials).get_organization_iam_policy(scope_id)
elsif scope_type == "folders"
MU::Cloud::Google.resource_manager(credentials: credentials).get_folder_iam_policy(scope_id)
elsif scope_type == "projects"
if !scope_id
raise MuError, "Google::Role.bindToIAM was called without a scope_id"
elsif scope_id.is_a?(Hash)
if scope_id["id"]
scope_id = scope_id["id"]
else
raise MuError, "Google::Role.bindToIAM was called with a scope_id Ref hash that has no id field"
end
end
MU::Cloud::Google.resource_manager(credentials: credentials).get_project_iam_policy(scope_id.sub(/^projects\//, ""))
else
puts "WTF DO WIT #{scope_type}"
end
saw_role = false
policy.bindings.each { |binding|
if binding.role == role_id
saw_role = true
if binding.members.include?(entity)
return # it's already bound, nothing needs doing
else
binding.members << entity
end
end
}
if !saw_role
policy.bindings << MU::Cloud::Google.resource_manager(:Binding).new(
role: role_id,
members: [entity]
)
end
MU.log "Granting #{role_id} to #{entity} in #{scope_id}", MU::NOTICE
req_obj = MU::Cloud::Google.resource_manager(:SetIamPolicyRequest).new(
policy: policy
)
if scope_type == "organizations"
MU::Cloud::Google.resource_manager(credentials: credentials).set_organization_iam_policy(
scope_id,
req_obj
)
elsif scope_type == "folders"
MU::Cloud::Google.resource_manager(credentials: credentials).set_folder_iam_policy(
scope_id,
req_obj
)
elsif scope_type == "projects"
MU::Cloud::Google.resource_manager(credentials: credentials).set_project_iam_policy(
scope_id,
req_obj
)
end
}
end
# Remove all bindings for the specified entity
# @param entity_type [String]: The kind of entity to bind; typically user, group, or domain
# @param entity_id [String]: The cloud identifier of the entity
# @param credentials [String]:
# @param noop [Boolean]: Just say what we'd do without doing it
def self.removeBindings(entity_type, entity_id, credentials: nil, noop: false)
scopes = {}
my_org = MU::Cloud::Google.getOrg(credentials)
if my_org
scopes["organizations"] = [my_org.name]
folders = MU::Cloud.resourceClass("Google", "Folder").find(credentials: credentials)
if folders and folders.size > 0
scopes["folders"] = folders.keys
end
end
projects = MU::Cloud.resourceClass("Google", "Habitat").find(credentials: credentials)
if projects and projects.size > 0
scopes["projects"] = projects.keys
end
scopes.each_pair { |scope_type, scope_ids|
scope_ids.each { |scope_id|
@@role_bind_semaphore.synchronize {
@@role_bind_scope_semaphores[scope_id] ||= Mutex.new
}
@@role_bind_scope_semaphores[scope_id].synchronize {
entity = entity_type.sub(/s$/, "")+":"+entity_id
policy = if scope_type == "organizations"
MU::Cloud::Google.resource_manager(credentials: credentials).get_organization_iam_policy(my_org.name)
elsif scope_type == "folders"
MU::Cloud::Google.resource_manager(credentials: credentials).get_folder_iam_policy(scope_id)
elsif scope_type == "projects"
MU::Cloud::Google.resource_manager(credentials: credentials).get_project_iam_policy(scope_id)
end
need_update = false
policy.bindings.each { |binding|
if binding.members.include?(entity)
MU.log "Unbinding #{binding.role} from #{entity} in #{scope_id}"
need_update = true
binding.members.delete(entity)
end
}
# XXX maybe drop bindings with 0 members?
next if !need_update or noop
req_obj = MU::Cloud::Google.resource_manager(:SetIamPolicyRequest).new(
policy: policy
)
if scope_type == "organizations"
MU::Cloud::Google.resource_manager(credentials: credentials).set_organization_iam_policy(
scope_id,
req_obj
)
elsif scope_type == "folders"
MU::Cloud::Google.resource_manager(credentials: credentials).set_folder_iam_policy(
scope_id,
req_obj
)
elsif scope_type == "projects"
MU::Cloud::Google.resource_manager(credentials: credentials).set_project_iam_policy(
scope_id,
req_obj
)
end
}
}
}
end
# Add role bindings for a given entity from its BoK config
# @param entity_type [String]: The kind of entity to bind; typically user, group, or domain
# @param entity_id [String]: The cloud identifier of the entity
# @param cfg [Hash]: A configuration block confirming to our own {MU::Cloud::Google::Role.ref_schema}
# @param credentials [String]:
def self.bindFromConfig(entity_type, entity_id, cfg, credentials: nil, deploy: nil, debug: false)
loglevel = debug ? MU::NOTICE : MU::DEBUG
return if !cfg
MU.log "Google::Role::bindFromConfig binding called for #{entity_type} #{entity_id}", loglevel, details: cfg
cfg.each { |binding|
if deploy and binding["role"]["name"] and !binding["role"]["id"]
role_obj = deploy.findLitterMate(name: binding["role"]["name"], type: "roles")
binding["role"]["id"] = role_obj.cloud_id if role_obj
end
["organizations", "projects", "folders"].each { |scopetype|
next if !binding[scopetype]
binding[scopetype].each { |scope|
# XXX resolution of Ref bits (roles, projects, and folders anyway; organizations and domains are direct)
MU::Cloud::Google::Role.bindToIAM(
binding["role"]["id"],
entity_type,
entity_id,
scopetype,
scope,
credentials: credentials
)
}
}
if binding["directories"]
binding["directories"].each { |dir|
# this is either an organization cloud_id, or the name of one
# of our credential sets, which we must map to an organization
# cloud id
creds = MU::Cloud::Google.credConfig(dir)
customer = if creds
my_org = MU::Cloud::Google.getOrg(dir)
if !my_org
raise MuError, "Google directory role binding specified directory #{dir}, which looks like one of our credential sets, but does not appear to map to an organization!"
end
my_org.owner.directory_customer_id
elsif dir.match(/^organizations\//)
# Not sure if there's ever a case where we can do this with
# an org that's different from the one our credentials go with
my_org = MU::Cloud::Google.getOrg(credentials, with_id: dir)
if !my_org
raise MuError, "Failed to retrieve #{dir} with credentials #{credentials} in Google directory role binding for role #{binding["role"].to_s}"
end
my_org.owner.directory_customer_id
else
# assume it's a raw customer id and hope for the best
dir
end
if !binding["role"]["id"].match(/^\d+$/)
resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_roles(customer)
if resp and resp.items
resp.items.each { |role|
if role.role_name == binding["role"]["id"]
binding["role"]["id"] = role.role_id
break
end
}
end
end
# Ensure we're using the stupid internal id, instead of the
# email field (which is the "real" id most of the time)
real_id = nil
if entity_type == "group"
found = MU::Cloud.resourceClass("Google", "Group").find(cloud_id: entity_id, credentials: credentials)
if found[entity_id]
real_id = found[entity_id].id
end
elsif entity_type == "user"
found = MU::Cloud.resourceClass("Google", "User").find(cloud_id: entity_id, credentials: credentials)
if found[entity_id]
real_id = found[entity_id].id
end
else
raise MuError, "I don't know how to identify entity type #{entity_type} with id #{entity_id} in directory role binding"
end
real_id ||= entity_id # fingers crossed
assign_obj = MU::Cloud::Google.admin_directory(:RoleAssignment).new(
assigned_to: real_id,
role_id: binding["role"]["id"],
scope_type: "CUSTOMER"
)
# XXX guard this mess
MU.log "Binding directory role #{(binding["role"]["name"] || binding["role"]["id"])} to #{entity_type} #{entity_id} in #{dir}", details: assign_obj
MU::Cloud::Google.admin_directory(credentials: credentials).insert_role_assignment(
customer,
assign_obj
)
}
end
}
# XXX whattabout GSuite-tier roles?
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
# Return the list of "container" resource types in which this resource
# can reside. The list will include an explicit nil if this resource
# can exist outside of any container.
# @return [Array]
def self.canLiveIn
[nil, :Habitat, :Folder]
end
# Denote whether this resource implementation is experiment, ready for
# testing, or ready for production use.
def self.quality
MU::Cloud::RELEASE
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: {})
customer = MU::Cloud::Google.customerID(credentials)
my_org = MU::Cloud::Google.getOrg(credentials)
filter = %Q{(labels.mu-id = "#{MU.deploy_id.downcase}")}
if !ignoremaster and MU.mu_public_ip
filter += %Q{ AND (labels.mu-master-ip = "#{MU.mu_public_ip.gsub(/\./, "_")}")}
end
MU.log "Placeholder: Google Role artifacts do not support labels, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: filter
if flags['known']
flags['known'].each { |id|
next if id.nil?
# GCP roles don't have a useful field for packing in our deploy
# id, so if we have metadata to leverage for this, use it. For
# directory roles, we try to make it into the name field, so
# we'll check that later, but for org and project roles this is
# our only option.
if my_org and id.is_a?(Integer) or id.match(/^\d+/)
begin
resp = MU::Cloud::Google.admin_directory(credentials: credentials).get_role(customer, id)
rescue ::Google::Apis::ClientError => e
next if e.message.match(/notFound/)
raise e
end
if resp
MU.log "Deleting directory role #{resp.role_name}"
if !noop
MU::Cloud::Google.admin_directory(credentials: credentials).delete_role(customer, id)
end
end
elsif id.match(/^projects\/.*?\/roles\//)
begin
resp = MU::Cloud::Google.iam(credentials: credentials).get_project_role(id)
rescue ::Google::Apis::ClientError => e
next if e.message.match(/notFound/)
raise e
end
if resp
MU.log "Deleting project role #{resp.name}"
if !noop
MU::Cloud::Google.iam(credentials: credentials).delete_project_role(id)
end
end
elsif id.match(/^organizations\//)
begin
resp = MU::Cloud::Google.iam(credentials: credentials).get_organization_role(id)
rescue ::Google::Apis::ClientError => e
#MU.log e.message, MU::ERR, details: id
#next
next if e.message.match(/notFound/)
raise e
end
if resp
MU.log "Deleting organization role #{resp.name}"
if !noop
MU::Cloud::Google.iam(credentials: credentials).delete_organization_role(id)
end
end
end
}
end
if my_org and MU.deploy_id and !MU.deploy_id.empty?
resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_roles(customer)
if resp and resp.items
resp.items.each { |role|
if role.role_name.match(/^#{Regexp.quote(MU.deploy_id)}/)
MU.log "Deleting directory role #{role.role_name}"
if !noop
MU::Cloud::Google.admin_directory(credentials: credentials).delete_role(customer, role.role_id)
end
end
}
end
end
end
# Locate and return cloud provider descriptors of this resource type
# which match the provided parameters, or all visible resources if no
# filters are specified. At minimum, implementations of +find+ must
# honor +credentials+ and +cloud_id+ arguments. We may optionally
# support other search methods, such as +tag_key+ and +tag_value+, or
# cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}.
# @param args [Hash]: Hash of named arguments passed via Ruby's double-splat
# @return [Hash]: The cloud provider's complete descriptions of matching resources
def self.find(**args)
credcfg = MU::Cloud::Google.credConfig(args[:credentials])
customer = MU::Cloud::Google.customerID(args[:credentials])
my_org = MU::Cloud::Google.getOrg(args[:credentials])
found = {}
args[:project] ||= args[:habitat]
if args[:project]
canned = Hash[MU::Cloud::Google.iam(credentials: args[:credentials]).list_roles.roles.map { |r| [r.name, r] }]
begin
MU::Cloud.resourceClass("Google", "Habitat").bindings(args[:project], credentials: args[:credentials]).each { |binding|
found[binding.role] = canned[binding.role]
}
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/forbidden: /)
end
resp = begin
MU::Cloud::Google.iam(credentials: args[:credentials]).list_project_roles("projects/"+args[:project])
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/forbidden: /)
end
if resp and resp.roles
resp.roles.each { |role|
found[role.name] = role
}
end
if args[:cloud_id]
found.reject! { |k, _v| k != role.name }
end
# Now go get everything that's bound here
bindings = MU::Cloud::Google::Role.getAllBindings(args[:credentials])
if bindings and bindings['by_scope'] and
bindings['by_scope']['projects'] and
bindings['by_scope']['projects'][args[:project]]
bindings['by_scope']['projects'][args[:project]].keys.each { |r|
if r.match(/^roles\//)
begin
role = MU::Cloud::Google.iam(credentials: args[:credentials]).get_role(r)
found[role.name] = role
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/(?:forbidden|notFound): /)
MU.log "Failed MU::Cloud::Google.iam(credentials: #{args[:credentials]}).get_role(#{r})", MU::WARN, details: e.message
end
elsif !found[r]
# MU.log "NEED TO GET #{r}", MU::WARN
end
}
end
else
if credcfg['masquerade_as']
if args[:cloud_id]
begin
resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).get_role(customer, args[:cloud_id].to_i)
if resp
found[args[:cloud_id].to_s] = resp
end
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/(?:forbidden|notFound): /)
end
else
resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).list_roles(customer)
if resp and resp.items
resp.items.each { |role|
found[role.role_id.to_s] = role
}
end
end
end
# These are the canned roles
resp = begin
MU::Cloud::Google.iam(credentials: args[:credentials]).list_roles
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/forbidden: /)
end
if resp
resp.roles.each { |role|
found[role.name] = role
}
end
if my_org
resp = begin
MU::Cloud::Google.iam(credentials: args[:credentials]).list_organization_roles(my_org.name)
rescue ::Google::Apis::ClientError => e
raise e if !e.message.match(/forbidden: /)
end
if resp and resp.roles
resp.roles.each { |role|
found[role.name] = role
}
end
end
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" => "Google",
"credentials" => @config['credentials'],
"cloud_id" => @cloud_id
}
my_org = MU::Cloud::Google.getOrg(@config['credentials'])
# This can happen if the role_source isn't set correctly. This logic
# maybe belongs inside cloud_desc. XXX
if cloud_desc.nil?
if @cloud_id and @cloud_id.match(/^roles\/(.*)/)
@config['role_source'] = "canned"
elsif @cloud_id and @cloud_id.match(/^organizations\/\d+\/roles\/(.*)/)
@config['role_source'] = "org"
elsif @cloud_id and @cloud_id.match(/^projects\/\d+\/roles\/(.*)/)
@config['role_source'] = "project"
end
end
# GSuite or Cloud Identity role
if cloud_desc.class == ::Google::Apis::AdminDirectoryV1::Role
return nil if cloud_desc.is_system_role
bok["name"] = @config['name'].gsub(/[^a-z0-9]/i, '-').downcase
bok['role_source'] = "directory"
bok["display_name"] = @config['name']
if !cloud_desc.role_description.empty?
bok["description"] = cloud_desc.role_description
end
if !cloud_desc.role_privileges.nil? and !cloud_desc.role_privileges.empty?
bok['import'] = []
ids, _names, _privs = MU::Cloud::Google::Role.privilege_service_to_name(@config['credentials'])
cloud_desc.role_privileges.each { |priv|
if !ids[priv.service_id]
MU.log "Role privilege defined for a service id with no name I can find, writing with raw id", MU::DEBUG, details: priv
bok["import"] << priv.service_id+"/"+priv.privilege_name
else
bok["import"] << ids[priv.service_id]+"/"+priv.privilege_name
end
}
bok['import'].sort! # at least be legible
end
else # otherwise it's a GCP IAM role of some kind
return nil if cloud_desc.stage == "DISABLED"
if cloud_desc.name.match(/^roles\/([^\/]+)$/)
name = Regexp.last_match[1]
bok['name'] = name.gsub(/[^a-z0-9]/i, '-')
bok['role_source'] = "canned"
elsif cloud_desc.name.match(/^([^\/]+?)\/([^\/]+?)\/roles\/(.*)/)
_junk, type, parent, name = Regexp.last_match.to_a
bok['name'] = name.gsub(/[^a-z0-9]/i, '-')
bok['role_source'] = type == "organizations" ? "org" : "project"
if bok['role_source'] == "project"
bok['project'] = parent
end
if cloud_desc.included_permissions and cloud_desc.included_permissions.size > 0
bok['import'] = cloud_desc.included_permissions
end
else
raise MuError, "I don't know how to parse GCP IAM role identifier #{cloud_desc.name}"
end
if !cloud_desc.description.nil? and !cloud_desc.description.empty?
bok["description"] = cloud_desc.description
end
bok["display_name"] = cloud_desc.title
bindings = MU::Cloud::Google::Role.getAllBindings(@config['credentials'])["by_role"][@cloud_id]
if bindings
refmap = {}
bindings.keys.each { |scopetype|
bindings[scopetype].each_pair { |scope_id, entity_types|
# If we've been given a habitat filter, skip over bindings
# that don't match it.
if scopetype == "projects"
if (args[:habitats] and !args[:habitats].empty? and
!args[:habitats].include?(scope_id)) or
!MU::Cloud::Google.listHabitats(@credentials).include?(scope_id)
next
end
end
entity_types.each_pair { |entity_type, entities|
next if entity_type == "deleted"
mu_entitytype = (entity_type == "serviceAccount" ? "user" : entity_type)+"s"
entities.each { |entity|
next if entity.nil?
foreign = if entity_type == "serviceAccount" and entity.match(/@(.*?)\.iam\.gserviceaccount\.com/)
!MU::Cloud::Google.listHabitats(@credentials).include?(Regexp.last_match[1])
end
entity_ref = if entity_type == "organizations"
{ "id" => ((org == my_org.name and @config['credentials']) ? @config['credentials'] : org) }
elsif entity_type == "domain"
{ "id" => entity }
else
shortclass, _cfg_name, _cfg_plural, _classname = MU::Cloud.getResourceNames(mu_entitytype)
if args[:types].include?(shortclass) and
!(entity_type == "serviceAccount" and
MU::Cloud::Google::User.cannedServiceAcctName?(entity))
MU.log "Role #{@cloud_id}: Skipping #{shortclass} binding for #{entity}; we are adopting that type and will set bindings from that resource", MU::DEBUG
next
end
MU::Config::Ref.get(
id: entity,
cloud: "Google",
type: mu_entitytype
)
end
if entity_ref.nil?
MU.log "I somehow ended up with a nil entity reference for #{entity_type} #{entity}", MU::ERR, details: [ bok, bindings ]
next
end
refmap ||= {}
refmap[entity_ref] ||= {}
refmap[entity_ref][scopetype] ||= []
mu_scopetype = scopetype == "projects" ? "habitats" : scopetype
if scopetype == "organizations" or scopetype == "domains" # XXX singular? plural? barf
refmap[entity_ref][scopetype] << ((scope_id == my_org.name and @config['credentials']) ? @config['credentials'] : scope_id)
else
refmap[entity_ref][scopetype] << MU::Config::Ref.get(
id: scope_id,
cloud: "Google",
type: mu_scopetype
)
end
refmap[entity_ref][scopetype].uniq!
}
}
}
}
bok["bindings"] ||= []
refmap.each_pair { |entity, scopes|
newbinding = { "entity" => entity }
scopes.keys.each { |scopetype|
newbinding[scopetype] = scopes[scopetype].sort
}
bok["bindings"] << newbinding
}
end
end
# Our only reason for declaring canned roles is so we can put their
# bindings somewhere. If there aren't any, then we don't need
# to bother with them.
if bok['role_source'] == "canned" and
(bok['bindings'].nil? or bok['bindings'].empty?)
return nil
end
bok
end
# Schema used by +user+ and +group+ entities to reference role
# assignments and their scopes.
# @return []
def self.ref_schema
{
"type" => "object",
"description" => "One or more Google IAM roles to associate with this entity. IAM roles in Google can be associated at the project (+Habitat+), folder, or organization level, so we must specify not only role, but each container in which it is granted to the entity in question.",
"properties" => {
"role" => MU::Config::Ref.schema(type: "roles"),
"projects" => {
"type" => "array",
"items" => MU::Config::Ref.schema(type: "habitats")
},
"folders" => {
"type" => "array",
"items" => MU::Config::Ref.schema(type: "folders")
},
"organizations" => {
"type" => "array",
"items" => {
"type" => "string",
"description" => "Either an organization cloud identifier, like +organizations/123456789012+, or the name of set of Mu credentials listed in +mu.yaml+, which can be used as an alias to the organization to which they authenticate."
}
}
}
}
end
@@binding_semaphore = Mutex.new
@@bindings_by_role = {}
@@bindings_by_entity = {}
@@bindings_by_scope = {}
# Retrieve IAM role bindings for all entities throughout our
# organization, map them in useful ways, and cache the result.
def self.getAllBindings(credentials = nil, refresh: false)
my_org = MU::Cloud::Google.getOrg(credentials)
@@binding_semaphore.synchronize {
if @@bindings_by_role.size > 0 and !refresh
return {
"by_role" => @@bindings_by_role.dup,
"by_scope" => @@bindings_by_scope.dup,
"by_entity" => @@bindings_by_entity.dup
}
end
def self.insertBinding(scopetype, scope, binding = nil, member_type: nil, member_id: nil, role_id: nil)
role_id = binding.role if binding
@@bindings_by_scope[scopetype] ||= {}
@@bindings_by_scope[scopetype][scope] ||= {}
@@bindings_by_scope[scopetype][scope][role_id] ||= {}
@@bindings_by_role[role_id] ||= {}
@@bindings_by_role[role_id][scopetype] ||= {}
@@bindings_by_role[role_id][scopetype][scope] ||= {}
do_binding = Proc.new { |type, id|
@@bindings_by_role[role_id][scopetype][scope][type] ||= []
@@bindings_by_role[role_id][scopetype][scope][type] << id
@@bindings_by_scope[scopetype][scope][role_id][type] ||= []
@@bindings_by_scope[scopetype][scope][role_id][type] << id
@@bindings_by_entity[type] ||= {}
@@bindings_by_entity[type][id] ||= {}
@@bindings_by_entity[type][id][role_id] ||= {}
@@bindings_by_entity[type][id][role_id][scopetype] ||= []
@@bindings_by_entity[type][id][role_id][scopetype] << scope
}
if binding
binding.members.each { |member|
member_type, member_id = member.split(/:/)
do_binding.call(member_type, member_id)
}
elsif member_type and member_id
do_binding.call(member_type, member_id)
end
end
if my_org
resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_role_assignments(MU::Cloud::Google.customerID(credentials))
resp.items.each { |binding|
begin
user = MU::Cloud::Google.admin_directory(credentials: credentials).get_user(binding.assigned_to)
insertBinding("directories", my_org.name, member_id: user.primary_email, member_type: "user", role_id: binding.role_id.to_s)
next
rescue ::Google::Apis::ClientError # notFound
end
begin
group = MU::Cloud::Google.admin_directory(credentials: credentials).get_group(binding.assigned_to)
MU.log "GROUP", MU::NOTICE, details: group
# insertBinding("directories", my_org.name, member_id: group.primary_email, member_type: "group", role_id: binding.role_id.to_s)
next
rescue ::Google::Apis::ClientError # notFound
end
role = MU::Cloud::Google.admin_directory(credentials: credentials).get_role(MU::Cloud::Google.customerID(credentials), binding.role_id)
MU.log "Failed to find entity #{binding.assigned_to} referenced in GSuite/Cloud Identity binding to role #{role.role_name}", MU::DEBUG, details: role
}
resp = MU::Cloud::Google.resource_manager(credentials: credentials).get_organization_iam_policy(my_org.name)
resp.bindings.each { |binding|
insertBinding("organizations", my_org.name, binding)
}
MU::Cloud.resourceClass("Google", "Folder").find(credentials: credentials).keys.each { |folder|
folder_bindings = MU::Cloud.resourceClass("Google", "Folder").bindings(folder, credentials: credentials)
next if !folder_bindings
folder_bindings.each { |binding|
insertBinding("folders", folder, binding)
}
}
end
MU::Cloud::Google.listHabitats(credentials).each { |project|
begin
MU::Cloud.resourceClass("Google", "Habitat").bindings(project, credentials: credentials).each { |binding|
insertBinding("projects", project, binding)
}
rescue ::Google::Apis::ClientError => e
if e.message.match(/forbidden: /)
MU.log "Do not have permissions to retrieve bindings in project #{project}, skipping", MU::WARN
else
raise e
end
end
}
return {
"by_role" => @@bindings_by_role.dup,
"by_scope" => @@bindings_by_scope.dup,
"by_entity" => @@bindings_by_entity.dup
}
}
end
# Convert a list of bindings of the type returned by {MU::Cloud::Google::Role.getAllBindings} into valid configuration language.
# @param roles [Hash]
# @param credentials [String]
# @return [Hash]
def self.entityBindingsToSchema(roles, credentials: nil)
my_org = MU::Cloud::Google.getOrg(credentials)
role_cfg = []
roles.each_pair { |role, scopes|
rolemap = { }
rolemap["role"] = if !role.is_a?(Integer) and role.match(/^roles\//)
# generally referring to a canned GCP role
{ "id" => role.to_s }
elsif role.is_a?(Integer) or role.match(/^\d+$/)
# If this is a GSuite/Cloud Identity system role, reference it by
# its human-readable name intead of its numeric id
role_desc = MU::Cloud::Google::Role.find(cloud_id: role, credentials: credentials).values.first
if role_desc.is_system_role
{ "id" => role_desc.role_name }
else
MU::Config::Ref.get(
id: role,
cloud: "Google",
credentials: credentials,
type: "roles"
)
end
else
# Possi-probably something we're declaring elsewhere in this
# adopted Mu stack
MU::Config::Ref.get(
id: role,
cloud: "Google",
credentials: credentials,
type: "roles"
)
end
scopes.each_pair { |scopetype, places|
if places.size > 0
rolemap[scopetype] = []
if scopetype == "organizations" or scopetype == "directories"
places.each { |org|
rolemap[scopetype] << ((org == my_org.name and credentials) ? credentials : org)
}
else
places.each { |place|
mu_type = scopetype == "projects" ? "habitats" : scopetype
rolemap[scopetype] << MU::Config::Ref.get(
id: place,
cloud: "Google",
credentials: credentials,
type: mu_type
)
}
end
end
}
role_cfg << rolemap
}
role_cfg
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 = []
schema = {
"name" => {
"pattern" => '^[a-zA-Z0-9\-\.\/]+$'
},
"display_name" => {
"type" => "string",
"description" => "A human readable name for this role. If not specified, will default to our long-form deploy-generated name."
},
"role_source" => {
"type" => "string",
"description" => "Google effectively has four types of roles:
+directory+: An admin role in GSuite or Cloud Identity
+org+: A custom organization-level IAM role. Note that these are only valid in GSuite or Cloud Identity environments
+project+: A custom project-level IAM role.
+canned+: A reference to one of the standard pre-defined IAM roles, usually only declared to apply {bindings} to other artifacts.
If this value is not specified, and the role name matches the name of an existing +canned+ role, we will assume it should be +canned+. If it does not, and we have credentials which map to a valid organization, we will assume +org+; if the credentials do not map to an organization, we will assume +project+.",
"enum" => ["directory", "org", "project", "canned"]
},
"description" => {
"type" => "string",
"description" => "Detailed human-readable description of this role's purpose"
},
"bindings" => {
"type" => "array",
"items" => {
"type" => "object",
"description" => "One or more entities (+user+, +group+, etc) to associate with this role. IAM roles in Google can be associated at the project (+Habitat+), folder, or organization level, so we must specify not only the target entity, but each container in which it is granted to the entity in question.",
"properties" => {
"entity" => MU::Config::Ref.schema,
"projects" => {
"type" => "array",
"items" => MU::Config::Ref.schema(type: "habitats")
},
"folders" => {
"type" => "array",
"items" => MU::Config::Ref.schema(type: "folders")
},
"organizations" => {
"type" => "array",
"items" => {
"type" => "string",
"description" => "Either an organization cloud identifier, like +organizations/123456789012+, or the name of set of Mu credentials, which can be used as an alias to the organization to which they authenticate."
}
}
}
}
}
}
[toplevel_required, schema]
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
MU::Cloud::Google.credConfig(role['credentials'])
my_org = MU::Cloud::Google.getOrg(role['credentials'])
if !role['role_source']
begin
lookup_name = role['name'].dup
if !lookup_name.match(/^roles\//)
lookup_name = "roles/"+lookup_name
end
MU::Cloud::Google.iam(credentials: role['credentials']).get_role(lookup_name)
MU.log "Role #{role['name']} appears to be a referenced to canned role #{role.name} (#{role.title})", MU::NOTICE
role['role_source'] = "canned"
rescue ::Google::Apis::ClientError
role['role_source'] = my_org ? "org" : "project"
end
end
if role['role_source'] == "canned"
if role['bindings'].nil? or role['bindings'].empty?
MU.log "Role #{role['name']} appears to refer to a canned role, but does not have any bindings declared- this will effectively do nothing.", MU::WARN
end
end
if role['role_source'] == "directory"
if role['import'] and role['import'].size > 0
mappings, missing = map_directory_privileges(role['import'], credentials: role['credentials'])
if mappings.size == 0
MU.log "None of the directory service privileges available to credentials #{role['credentials']} map to the ones declared for role #{role['name']}", MU::ERR, details: role['import'].sort
ok = false
elsif missing.size > 0
MU.log "Some directory service privileges declared for role #{role['name']} aren't available to credentials #{role['credentials']}, will skip", MU::DEBUG, details: missing
end
end
end
if role['role_source'] == "directory" or role['role_source'] == "org"
if !my_org
MU.log "Role #{role['name']} requires an organization/directory, but credential set #{role['credentials']} doesn't appear to have access to one", MU::ERR
ok = false
end
end
if role['role_source'] == "project"
role['project'] ||= MU::Cloud::Google.defaultProject(role['credentials'])
if configurator.haveLitterMate?(role['project'], "habitats")
MU::Config.addDependency(role, role['project'], "habitat")
end
end
if role['bindings']
role['bindings'].each { |binding|
if binding['entity'] and binding['entity']['name'] and
configurator.haveLitterMate?(binding['entity']['name'], binding['entity']['type'])
MU::Config.addDependency(role, binding['entity']['name'], binding['entity']['type'])
end
}
role['bindings'].uniq!
end
ok
end
@@service_id_to_name = {}
@@service_id_to_privs = {}
@@service_name_to_id = {}
@@service_name_map_semaphore = Mutex.new
# Generate lookup tables mapping between hex service role identifiers,
# human-readable names of services, and the privileges associated with
# those roles.
# @param credentials [String]
# @return [Array]
def self.privilege_service_to_name(credentials = nil)
customer = MU::Cloud::Google.customerID(credentials)
@@service_name_map_semaphore.synchronize {
if !@@service_id_to_name[credentials] or
!@@service_id_to_privs[credentials] or
!@@service_name_to_id[credentials]
@@service_id_to_name[credentials] ||= {}
@@service_id_to_privs[credentials] ||= {}
@@service_name_to_id[credentials] ||= {}
resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_privileges(customer)
def self.id_map_recurse(items, parent_name = nil)
id_to_name = {}
name_to_id = {}
id_to_privs = {}
items.each { |p|
svcname = p.service_name || parent_name
if svcname
id_to_name[p.service_id] ||= svcname
name_to_id[svcname] ||= p.service_id
else
# MU.log "FREAKING #{p.service_id} HAS NO NAME", MU::WARN
end
id_to_privs[p.service_id] ||= []
id_to_privs[p.service_id] << p.privilege_name
if p.child_privileges
ids, names, privs = id_map_recurse(p.child_privileges, svcname)
id_to_name.merge!(ids)
name_to_id.merge!(names)
privs.each_pair { |id, childprivs|
id_to_privs[id] ||= []
id_to_privs[id].concat(childprivs)
}
end
}
[id_to_name, name_to_id, id_to_privs]
end
@@service_id_to_name[credentials], @@service_id_to_privs[credentials], @@service_name_to_id[credentials] = self.id_map_recurse(resp.items)
end
return [@@service_id_to_name[credentials], @@service_id_to_privs[credentials], @@service_name_to_id[credentials]]
}
end
# Convert a list of shorthand GSuite/Cloud Identity privileges into
# +RolePrivilege+ objects for consumption by other API calls
# @param roles [Array]:
# @param credentials [String]:
# @return [Array]
def self.map_directory_privileges(roles, credentials: nil)
rolepriv_objs = []
notfound = []
if roles
ids, names, privlist = MU::Cloud::Google::Role.privilege_service_to_name(credentials)
roles.each { |p|
service, privilege = p.split(/\//)
if !names[service] and !ids[service]
notfound << service
elsif !privlist[names[service]].include?(privilege)
notfound << p
elsif names[service]
rolepriv_objs << MU::Cloud::Google.admin_directory(:Role)::RolePrivilege.new(
privilege_name: privilege,
service_id: names[service]
)
else
rolepriv_objs << MU::Cloud::Google.admin_directory(:Role)::RolePrivilege.new(
privilege_name: privilege,
service_id: service
)
end
}
end
[rolepriv_objs, notfound.uniq.sort]
end
end
end
end
end