blob: 15a6d4b1a3db6324a8259f08b384f0be8431616a [file] [log] [blame]
# Copyright 2015 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""A registry of known bots and server-side assigned (trusted) dimensions.
It is fetched from the config service. Functions here are used by bot API
handlers in handlers_bot.py.
"""
import logging
from components import auth
from components.auth import ipaddr
import ts_mon_metrics
from server import bot_groups_config
def is_authenticated_bot(bot_id):
"""Returns True if bot with given ID is using correct credentials.
Expected to be called in a context of a handler of a request coming from the
bot with given ID.
"""
try:
authenticate_bot(bot_id)
return True
except auth.AuthorizationError:
return False
def authenticate_bot(bot_id):
"""Verifies ID reported by a bot matches the credentials being used.
Expected to be called in a context of some bot API request handler. Uses
bots.cfg config to look up what credentials are expected to be used by the bot
with given ID.
Raises auth.AuthorizationError if bot_id is unknown or bot is using invalid
credentials.
Returns:
(BotGroupConfig with bot config, BotAuth with auth method used).
"""
if not bot_id:
raise auth.AuthorizationError('Bot ID is not specified')
# Look up this bot in the bots.cfg to know what authentication credentials
# it should be sending.
cfg = bot_groups_config.get_bot_group_config(bot_id)
if not cfg:
logging.error(
'bot_auth: unknown bot_id, not in the config\n'
'bot_id: "%s"', bot_id)
raise auth.AuthorizationError('Unknown bot ID, not in config')
# This should not really happen for validated configs.
if not cfg.auth:
logging.error('bot_auth: no auth configured in bots.cfg')
raise auth.AuthorizationError('No auth configured in bots.cfg')
ip = auth.get_peer_ip()
peer_ident = auth.get_peer_identity()
# Authentication tokens always use the host bot ID. See comments for
# bot_groups_config.get_host_bot_id(...).
host_bot_id = bot_groups_config.get_host_bot_id(bot_id)
# Errors from all auth methods.
auth_errs = []
# Logs to emit if all methods fail. Omitted if some method succeeds.
delayed_logs = []
# Try all auth methods sequentially until a first success. When migrating
# between different methods it may be important to know when a method is
# skipped. Logs from such methods are always emitted at 'error' level. Other
# logs are buffered and emitted only if all methods fail.
for bot_auth in cfg.auth:
err, details = _check_bot_auth(bot_auth, host_bot_id, peer_ident, ip)
if not err:
logging.debug('Using auth method: %s', bot_auth)
return cfg, bot_auth
auth_errs.append(err)
if bot_auth.log_if_failed:
logging.error('Preferred auth method failed: %s', err)
logging.error('Failed auth method: %s', bot_auth)
for msg in details:
logging.error('%s', msg)
else:
delayed_logs.append('Auth method failed: %s' % (err,))
delayed_logs.append('Failed auth method: %s' % (bot_auth,))
delayed_logs.extend(details)
# All fallback methods failed. Need their logs to investigate.
for msg in delayed_logs:
logging.error('%s', msg)
# In most cases there's only one auth method used, so we can simplify the
# error message to be less confusing.
if len(auth_errs) == 1:
raise auth.AuthorizationError(auth_errs[0])
raise auth.AuthorizationError(
'All auth methods failed: %s' % '; '.join(auth_errs))
def _check_bot_auth(bot_auth, bot_id, peer_ident, ip):
"""Checks whether a bot matches some authorization method.
Args:
bot_auth: an instance of bot_groups_config.BotAuth with auth config.
bot_id: ID of the bot host, as sent inside the RPC body.
peer_ident: Identity the bot had authenticated with.
ip: IP address of the bot.
Returns:
(None, []) on success.
(Public error message, list of internal error messages) on failure.
"""
errors = []
def error(msg, *args):
errors.append(msg % args)
# Check that IP allowlist applies (in addition to credentials), and increment
# the monitoring counter with number of successful auth events.
def check_ip_and_finish(auth_method, condition):
if bot_auth.ip_whitelist:
if not auth.is_in_ip_whitelist(bot_auth.ip_whitelist, ip):
error(
'bot_auth: bot IP is not in the allowlist\n'
'bot_id: "%s", peer_ip: "%s", ip_whitelist: "%s"', bot_id,
ipaddr.ip_to_string(ip), bot_auth.ip_whitelist)
return 'IP not allowed', errors
ts_mon_metrics.on_bot_auth_success(auth_method, condition)
return None, []
if bot_auth.require_luci_machine_token:
if not _is_valid_ident_for_bot(peer_ident, bot_id):
error(
'bot_auth: bot ID doesn\'t match the machine token used\n'
'bot_id: "%s", peer_ident: "%s"',
bot_id, peer_ident.to_bytes())
return 'Bot ID doesn\'t match the token used', errors
return check_ip_and_finish('luci_token', '-')
if bot_auth.require_service_account:
expected_ids = [
auth.Identity(auth.IDENTITY_USER, email)
for email in bot_auth.require_service_account
]
if peer_ident not in expected_ids:
error(
'bot_auth: bot is not using expected service account\n'
'bot_id: "%s", expected_id: %s, peer_ident: "%s"',
bot_id, [i.to_bytes() for i in expected_ids], peer_ident.to_bytes())
if peer_ident.is_anonymous:
error(
'Bot is identifying as anonymous. Is the "userinfo" scope enabled '
'for this instance?')
return 'Bot is not using expected service account', errors
return check_ip_and_finish('service_account', peer_ident.name)
if bot_auth.require_gce_vm_token:
expected_proj = bot_auth.require_gce_vm_token.project
details = auth.get_auth_details()
bot_vm_inst = details.gce_instance
bot_vm_proj = details.gce_project
if not bot_vm_proj:
error(
'bot_auth: bot is not using X-Luci-Gce-Vm-Token auth\n'
'bot_id: "%s", peer_ident: "%s", expected_proj: "%s"',
bot_id, peer_ident.to_bytes(), expected_proj)
return 'Bot is expected to send X-Luci-Gce-Vm-Token, it didn\'t', errors
if bot_vm_proj != expected_proj:
error(
'bot_auth: got GCE VM token from unexpected project\n'
'bot_id: "%s", peer_ident: "%s", expected_proj: "%s"',
bot_id, peer_ident.to_bytes(), expected_proj)
return 'Unexpected GCE project %s in the auth token' % bot_vm_proj, errors
if bot_vm_inst != bot_id:
error(
'bot_auth: bot ID and GCE instance name do not match\n'
'bot_id: "%s", peer_ident: "%s"', bot_id, peer_ident.to_bytes())
return (
'Bot ID %s doesn\'t match GCE instance ID %s' % (bot_id, bot_vm_proj),
errors
)
return check_ip_and_finish('gce_vm_token', expected_proj)
if bot_auth.ip_whitelist:
return check_ip_and_finish('ip_whitelist', bot_auth.ip_whitelist)
# This branch should not be hit for validated configs.
error(
'bot_auth: invalid bot group config, no auth method defined\n'
'bot_id: "%s"', bot_id)
return 'Invalid bot group config', errors
def _is_valid_ident_for_bot(ident, bot_id):
"""True if bot_id matches the identity derived from a machine token.
bot_id is usually hostname, and the identity derived from a machine token is
'bot:<fqdn>', so we validate that <fqdn> starts with '<bot_id>.'.
We also explicitly skip magical 'bot:ip-whitelisted' identity assigned to
bots that use 'bots' IP allowlist for auth (not tokens).
"""
# TODO(vadimsh): Should bots.cfg also contain a list of allowed domain names,
# so this check is stricter?
return (
ident.kind == auth.IDENTITY_BOT and
ident != auth.IP_WHITELISTED_BOT_ID and
ident.name.startswith(bot_id + '.'))