# 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 Google # A group as configured in {MU::Config::BasketofKittens::groups} class Group < MU::Cloud::Group # 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 ||= @deploy.getResourceName(@config["name"]) end # Called automatically by {MU::Deploy#createResources} def create if !@config['external'] if !@config['email'] domains = MU::Cloud::Google.admin_directory(credentials: @credentials).list_domains(@customer) @config['email'] = @mu_name.downcase+"@"+domains.domains.first.domain_name end group_obj = MU::Cloud::Google.admin_directory(:Group).new( name: @mu_name, email: @config['email'], description: @deploy.deploy_id ) MU.log "Creating group #{@mu_name}", details: group_obj resp = MU::Cloud::Google.admin_directory(credentials: @credentials).insert_group(group_obj) @cloud_id = resp.email MU::Cloud::Google::Role.bindFromConfig("group", @cloud_id, @config['roles'], credentials: @config['credentials']) else @cloud_id = @config['name'].sub(/@.*/, "")+"@"+@config['domain'] end end # Called automatically by {MU::Deploy#createResources} def groom MU::Cloud::Google::Role.bindFromConfig("group", @cloud_id, @config['roles'], credentials: @config['credentials'], debug: true) if @config['members'] resolved_desired = [] @config['members'].each { |m| sibling_user = @deploy.findLitterMate(name: m, type: "users") usermail = if sibling_user sibling_user.cloud_id elsif !m.match(/@/) domains = MU::Cloud::Google.admin_directory(credentials: @credentials).list_domains(@customer) m+"@"+domains.domains.first.domain_name else m end resolved_desired << usermail next if members.include?(usermail) MU.log "Adding user #{usermail} to group #{@mu_name}" MU::Cloud::Google.admin_directory(credentials: @credentials).insert_member( @cloud_id, MU::Cloud::Google.admin_directory(:Member).new( email: usermail ) ) } deletia = members - resolved_desired deletia.each { |m| MU.log "Removing user #{m} from group #{@mu_name}", MU::NOTICE MU::Cloud::Google.admin_directory(credentials: @credentials).delete_member(@cloud_id, m) } # Theoretically there can be a delay begin if members.sort != resolved_desired.sort sleep 3 end end while members.sort != resolved_desired.sort end end # Retrieve a list of users (by cloud id) of this group def members resp = MU::Cloud::Google.admin_directory(credentials: @credentials).list_members(@cloud_id) members = [] if resp and resp.members members = resp.members.map { |m| m.email } # XXX reject status != "ACTIVE" ? end members end # Return the metadata for this group configuration # @return [Hash] def notify if !@config['external'] base = MU.structToHash(cloud_desc) end base ||= {} base 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] 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 groups 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 # @param region [String]: The cloud provider region # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) my_domains = MU::Cloud::Google.getDomains(credentials) my_org = MU::Cloud::Google.getOrg(credentials) if my_org groups = MU::Cloud::Google.admin_directory(credentials: credentials).list_groups(customer: MU::Cloud::Google.customerID(credentials)).groups if groups groups.each { |group| if group.description == MU.deploy_id MU.log "Deleting group #{group.name} from #{my_org.display_name}", details: group if !noop MU::Cloud::Google.admin_directory(credentials: credentials).delete_group(group.id) end end } end end if flags['known'] flags['known'].each { |group| MU::Cloud::Google::Role.removeBindings("group", group, credentials: credentials, noop: noop) } 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) found = {} # The API treats the email address field as its main identifier, so # we'll go ahead and respect that. if args[:cloud_id] begin resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).get_group(args[:cloud_id]) found[resp.email] = resp if resp rescue ::Google::Apis::ClientError => e raise e if !e.message.match(/forbidden: /) end else resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).list_groups(customer: MU::Cloud::Google.customerID(args[:credentials])) if resp and resp.groups found = Hash[resp.groups.map { |g| [g.email, g] }] end end # XXX what about Google Groups groups and other external groups? Where do we fish for those? Do we even need to? 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(rootparent: nil, billing: nil, habitats: nil) bok = { "cloud" => "Google", "credentials" => @config['credentials'] } bok['name'] = cloud_desc.name bok['cloud_id'] = cloud_desc.email bok['members'] = members bok['members'].each { |m| m = MU::Config::Ref.get( id: m, cloud: "Google", credentials: @config['credentials'], type: "users" ) } group_roles = MU::Cloud::Google::Role.getAllBindings(@config['credentials'])["by_entity"] if group_roles["group"] and group_roles["group"][bok['cloud_id']] and group_roles["group"][bok['cloud_id']].size > 0 bok['roles'] = MU::Cloud::Google::Role.entityBindingsToSchema(group_roles["group"][bok['cloud_id']], credentials: @config['credentials']) end bok 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" => { "type" => "string", "description" => "This can include an optional @domain component (foo@example.com). If the domain portion is not specified, and we manage exactly one GSuite or Cloud Identity domain, we will attempt to create the group in that domain. If we do not manage any domains, and none are specified, we will assume @googlegroups.com for the domain and attempt to bind an existing external Google Group to roles under our jurisdiction. If the domain portion is specified, and our credentials can manage that domain via GSuite or Cloud Identity, we will attempt to create the group in that domain. If it is a domain we do not manage, we will attempt to bind an existing external group from that domain to roles under our jurisdiction. If we are binding (rather than creating) a group and no roles are specified, we will default to +roles/viewer+ at the organization scope. If our credentials do not manage an organization, we will grant this role in our default project. " }, "domain" => { "type" => "string", "description" => "The domain from which the group originates or in which it should be created. This can instead be embedded in the {name} field: +foo@example.com+." }, "external" => { "type" => "boolean", "description" => "Explicitly flag this group as originating from an external domain. This should always autodetect correctly." }, "roles" => { "type" => "array", "items" => MU::Cloud::Google::Role.ref_schema } } [toplevel_required, schema] end # Cloud-specific pre-processing of {MU::Config::BasketofKittens::groups}, bare and unvalidated. # @param group [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(group, configurator) ok = true my_domains = MU::Cloud::Google.getDomains(group['credentials']) my_org = MU::Cloud::Google.getOrg(group['credentials']) if group['name'].match(/@(.*+)$/) domain = Regexp.last_match[1].downcase if domain and group['domain'] and domain != group['domain'].downcase MU.log "Group #{group['name']} had a domain component, but the domain field was also specified (#{group['domain']}) and they don't match." ok = false end group['domain'] = domain if !my_domains or !my_domains.include?(domain) group['external'] = true if !["googlegroups.com", "google.com"].include?(domain) MU.log "#{group['name']} appears to be a member of a domain that our credentials (#{group['credentials']}) do not manage; attempts to grant access for this group may fail!", MU::WARN end if !group['roles'] or group['roles'].empty? group['roles'] = [ { "role" => { "id" => "roles/viewer" } } ] if my_org group['roles'][0]["organizations"] = [my_org.name] else group['roles'][0]["projects"] = { "id" => group["project"] } end MU.log "External Google group specified with no role binding, will grant 'viewer' in #{my_org ? "organization #{my_org.display_name}" : "project #{group['project']}"}", MU::WARN end end else if !group['domain'] if my_domains.size == 1 group['domain'] = my_domains.first elsif my_domains.size > 1 MU.log "Google interactive User #{group['name']} did not specify a domain, and we have multiple defaults available. Must specify exactly one.", MU::ERR, details: my_domains ok = false else group['domain'] = "googlegroups.com" end end end credcfg = MU::Cloud::Google.credConfig(group['credentials']) if group['external'] and group['members'] MU.log "Cannot manage memberships for external group #{group['name']}", MU::ERR if group['domain'] == "googlegroups.com" MU.log "Visit https://groups.google.com to manage Google Groups.", MU::ERR end ok = false end if group['members'] group['members'].each { |m| if configurator.haveLitterMate?(m, "users") group['dependencies'] ||= [] group['dependencies'] << { "name" => m, "type" => "user" } end } end if group['roles'] group['roles'].each { |r| if r['role'] and r['role']['name'] and (!r['role']['deploy_id'] and !r['role']['id']) group['dependencies'] ||= [] group['dependencies'] << { "type" => "role", "name" => r['role']['name'] } end } end ok end private end end end end