class SimplyGenius::Atmos::Providers::Aws::AuthManager

Public Class Methods

new(provider) click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 19
def initialize(provider)
  @provider = provider
end

Public Instance Methods

authenticate(system_env, **opts, &block) click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 23
          def authenticate(system_env, **opts, &block)

            profile = system_env['AWS_PROFILE']
            key = system_env['AWS_ACCESS_KEY_ID']
            secret = system_env['AWS_SECRET_ACCESS_KEY']
            # dont warn if default profile
            no_creds = ::Aws::SharedCredentials.new.credentials.nil? rescue true

            if profile.blank? && (key.blank? || secret.blank?) && no_creds
              logger.warn("No AWS credentials are active in the environment nor shared credential store")
              logger.warn("Run 'aws configure' to add some to the shared credential store")
            end
            if profile.present? && key.present?
              logger.warn("Ignoring AWS_PROFILE because AWS_ACCESS_KEY_ID is set in the environment")
            end

            if Atmos.config["auth.bypass"]
              logger.warn("Bypassing atmos aws authentication")
              return block.call(Hash[system_env])
            end

            # Handle bootstrapping a new env account.  Newly created organization
            # accounts only have the default role that can only be assumed by an
            # iam user, so use that as the target for assume_role, and the root
            # check below will ensure iam user
            assume_role_name = nil
            if opts[:bootstrap] && Atmos.config.atmos_env != 'ops'
              # TODO: do this hack better
              assume_role_name = Atmos.config["auth.bootstrap_assume_role_name"]
              @session_duration = 3600
            else
              assume_role_name = opts[:role] || Atmos.config["auth.assume_role_name"]
            end
            account_id = Atmos.config.account_hash[Atmos.config.atmos_env].to_s
            role_arn = "arn:aws:iam::#{account_id}:role/#{assume_role_name}"

            user_name = nil
            begin
              sts = ::Aws::STS::Client.new
              resp = sts.get_caller_identity
              arn_pieces = resp.arn.split(":")
              user_name = arn_pieces.last.split("/").last

              # root credentials can't assume role, but they should have full
              # access for the current account, so proceed (e.g. for bootstrap).
              if arn_pieces.last == "root"

                # We check the account of the caller to prevent root user of ops
                # account from bootstrapping an env account, but still allow a
                # root user of the env account itself to be able to bootstrap
                # (i.e. to allow not organizational accounts to bootstrap using
                # their root user)
                if arn_pieces[-2] != account_id
                  logger.error <<~EOF
                    Account doesn't match credentials.  Bootstrapping a new
                    account should be done as an iam user from the ops account or
                    using credentials for a root user of the env account.
                  EOF
                  exit(1)
                end

                # Should only use root credentials for  bootstrap, and thus we
                # won't have role requirement for mfa, etc, even if root account
                # uses mfa for login.  Thus skip all the other stuff, to
                # encourage/force use of non-root accounts for normal use
                logger.warn("Using aws root credentials - should only be neccessary for bootstrap")
                return block.call(Hash[system_env])
              end

            rescue ::Aws::STS::Errors::ServiceError => e
              logger.error "Could not discover aws credentials"
              exit(1)
            end

            auth_needed = true
            cache_key = "#{user_name}-#{assume_role_name}"
            credentials = read_auth_cache[cache_key]

            if credentials.present?
              logger.debug("Session cache present, checking expiration...")
              expiration = Time.parse(credentials['expiration'])
              session_renew_interval = (session_duration / 4).to_i

              if Time.now > expiration
                logger.debug "Session cache is expired, performing normal auth"
                auth_needed = true
              elsif Time.now > (expiration - session_renew_interval)
                begin
                  # TODO: investigate making all info a warn so we don't pollute stdout for shell scripts
                  logger.info "Session approaching expiration, renewing..."
                  credentials = assume_role(role_arn, credentials: credentials, user_name: user_name)
                  write_auth_cache(cache_key => credentials)
                  auth_needed = false
                rescue => e
                  logger.info "Failed to renew credentials using session cache, reason: #{e.message}"
                  auth_needed = true
                end
              else
                logger.debug "Session cache is current, skipping auth"
                auth_needed = false
              end
            end

            if auth_needed
              begin
                logger.info "No active session cache, authenticating..."

                credentials = assume_role(role_arn, user_name: user_name)
                write_auth_cache(cache_key => credentials)

              rescue ::Aws::STS::Errors::AccessDenied => e
                if e.message !~ /explicit deny/
                  logger.debug "Access Denied, reason: #{e.message}"
                end

                logger.info "Normal auth failed, checking for mfa"

                iam = ::Aws::IAM::Client.new
                response = iam.list_mfa_devices(user_name: user_name)
                mfa_serial = response.mfa_devices.first.try(:serial_number)
                token = nil
                if mfa_serial.present?

                  token = Otp.instance.generate(user_name)
                  if token.nil?
                    token = ask("Enter token to retry with mfa: ")
                  else
                    logger.info "Used integrated atmos mfa to generate token"
                  end

                  if token.blank?
                    logger.error "A MFA token must be supplied"
                    exit(1)
                  end

                else
                  logger.error "MFA is not setup for your account, retry after doing so"
                  exit(1)
                end

                credentials = assume_role(role_arn, serial_number: mfa_serial, token_code: token, user_name: user_name)
                write_auth_cache(cache_key => credentials)

              end
            end

            process_env = {}
            process_env['AWS_ACCESS_KEY_ID'] = credentials['access_key_id']
            process_env['AWS_SECRET_ACCESS_KEY'] = credentials['secret_access_key']
            process_env['AWS_SESSION_TOKEN'] = credentials['session_token']
            logger.debug("Calling authentication target with env: #{process_env.inspect}")
            block.call(Hash[system_env].merge(process_env))
          end

Private Instance Methods

assume_role(role_arn, **opts) click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 183
def assume_role(role_arn, **opts)
  # use Aws::AssumeRoleCredentials ?
  if opts[:credentials]
    c = opts.delete(:credentials)
    creds = ::Aws::Credentials.new(
        c[:access_key_id], c[:secret_access_key], c[:session_token]
    )
    client_opts = {credentials: creds}
  else
    client_opts = {}
  end

  user_name = opts.delete(:user_name)
  if user_name
    session_name = "Atmos-#{user_name}"
  else
    session_name = "Atmos"
  end

  sts = ::Aws::STS::Client.new(client_opts)
  params = {
      duration_seconds: session_duration,
      role_session_name: session_name,
      role_arn: role_arn
  }.merge(opts)
  logger.debug("Assuming role: #{params}")
  resp = sts.assume_role(params)
  return Utils::SymbolizedMash.new(resp.credentials.to_h)
end
auth_cache_file() click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 213
def auth_cache_file
  File.join(Atmos.config.auth_cache_dir, 'aws-assume-role.json')
end
read_auth_cache() click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 223
def read_auth_cache
  data = JSON.parse(File.read(auth_cache_file)) rescue {}
  Utils::SymbolizedMash.new(data)
end
session_duration() click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 179
def session_duration
  @session_duration ||= (Atmos.config["auth.session_duration"] || 3600).to_i
end
write_auth_cache(h) click to toggle source
# File lib/simplygenius/atmos/providers/aws/auth_manager.rb, line 217
def write_auth_cache(h)
  File.open(auth_cache_file, 'w') do |f|
    f.puts(JSON.pretty_generate(h))
  end
end