blob: c4e3ef7138955d310eae91a213f7cb36d79b9d8f [file] [log] [blame]
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""User-related functions, including access control list implementation.
See Acl message in proto/project_config.proto.
"""
import collections
import logging
import os
import threading
from google.appengine.api import app_identity
from google.appengine.ext import ndb
from components import auth
from components import utils
from protorpc import messages
from go.chromium.org.luci.buildbucket.proto import project_config_pb2
import config
import errors
# Group whitelisting users to update builds. They are expected to be robots.
UPDATE_BUILD_ALLOWED_USERS = 'buildbucket-update-build-users'
################################################################################
## Permissions-based API (implemented in terms of Buildbucket roles for now).
ALL_PERMISSIONS = set()
def _permission(name):
perm = auth.Permission(name)
ALL_PERMISSIONS.add(perm)
return perm
# Builds permissions.
# See all information about a build.
PERM_BUILDS_GET = _permission('buildbucket.builds.get')
# List and search builds in a bucket.
PERM_BUILDS_LIST = _permission('buildbucket.builds.list')
# Schedule new builds in the bucket.
PERM_BUILDS_ADD = _permission('buildbucket.builds.add')
# Cancel a build in the bucket.
PERM_BUILDS_CANCEL = _permission('buildbucket.builds.cancel')
# Lease and control a build via v1 API, deprecated.
PERM_BUILDS_LEASE = _permission('buildbucket.builds.lease')
# Unlease and reset state of an existing build via v1 API, deprecated.
PERM_BUILDS_RESET = _permission('buildbucket.builds.reset')
# Builders permissions.
# See existence and metadata of a builder (but not its builds).
PERM_BUILDERS_GET = _permission('buildbucket.builders.get')
# List and search builders (but not builds).
PERM_BUILDERS_LIST = _permission('buildbucket.builders.list')
# Set the next build number.
PERM_BUILDERS_SET_NUM = _permission('buildbucket.builders.setBuildNumber')
# Bucket permissions.
# See existence of a bucket, used only by v1 APIs, deprecated.
PERM_BUCKETS_GET = _permission('buildbucket.buckets.get')
# Delete all scheduled builds from a bucket.
PERM_BUCKETS_DELETE_BUILDS = _permission('buildbucket.buckets.deleteBuilds')
# Pause/resume leasing builds in a bucket via v1 API, deprecated.
PERM_BUCKETS_PAUSE = _permission('buildbucket.buckets.pause')
# Forbid adding more permission from other modules or tests after this point.
ALL_PERMISSIONS = frozenset(ALL_PERMISSIONS)
# Maps a Permission to a minimum required project_config_pb2.Acl.Role.
PERM_TO_MIN_ROLE = {
# Reader.
PERM_BUILDS_GET: project_config_pb2.Acl.READER,
PERM_BUILDS_LIST: project_config_pb2.Acl.READER,
PERM_BUILDERS_GET: project_config_pb2.Acl.READER,
PERM_BUILDERS_LIST: project_config_pb2.Acl.READER,
PERM_BUCKETS_GET: project_config_pb2.Acl.READER,
# Scheduler.
PERM_BUILDS_ADD: project_config_pb2.Acl.SCHEDULER,
PERM_BUILDS_CANCEL: project_config_pb2.Acl.SCHEDULER,
# Writer.
PERM_BUILDS_LEASE: project_config_pb2.Acl.WRITER,
PERM_BUILDS_RESET: project_config_pb2.Acl.WRITER,
PERM_BUILDERS_SET_NUM: project_config_pb2.Acl.WRITER,
PERM_BUCKETS_DELETE_BUILDS: project_config_pb2.Acl.WRITER,
PERM_BUCKETS_PAUSE: project_config_pb2.Acl.WRITER,
}
assert sorted(PERM_TO_MIN_ROLE.keys()) == sorted(ALL_PERMISSIONS)
@ndb.tasklet
def has_perm_async(perm, bucket_id):
"""Returns True if the caller has the given permission in the bucket.
Args:
perm: an instance of auth.Permission.
bucket_id: a bucket ID string, i.e. "<project>/<bucket>".
"""
assert isinstance(perm, auth.Permission), perm
assert perm in ALL_PERMISSIONS, perm
config.validate_bucket_id(bucket_id)
# Convert to a realm ID (it uses ':' separator).
project, bucket = config.parse_bucket_id(bucket_id)
realm = '%s:%s' % (project, bucket)
# In buckets that have realm ACLs configured, enforce them.
if auth.should_enforce_realm_acl(realm):
logging.info('crbug.com/1091604: enforcing realm ACLs for %r', realm)
if auth.has_permission(perm, [realm]):
raise ndb.Return(True)
# For compatibility with legacy ALCs, administrators have implicit access to
# everything. Log when this rule is invoked, since it's surprising and it
# something we might want to get rid of after everything is migrated to
# Realms.
if auth.is_admin():
logging.warning(
'ADMIN_ACCESS: %r does not have permission %r in bucket %r, '
'but they are in %r group and are allowed to proceed',
auth.get_current_identity().to_bytes(), perm, bucket_id,
auth.ADMIN_GROUP
)
raise ndb.Return(True)
raise ndb.Return(False)
# Get the result of the legacy ACL check.
role = yield get_role_async_deprecated(bucket_id)
outcome = role is not None and role >= PERM_TO_MIN_ROLE[perm]
# Compare it to realm ACLs, logs the difference.
auth.has_permission_dryrun(
perm,
[realm],
expected_result=outcome,
admin_group=auth.ADMIN_GROUP,
tracking_bug='crbug.com/1091604',
)
# But still use legacy ACLs.
raise ndb.Return(outcome)
def has_perm(perm, bucket_id):
"""Returns True if the caller has the given permission in the bucket.
Args:
perm: an instance of auth.Permission.
bucket_id: a bucket ID string, i.e. "<project>/<bucket>".
"""
return has_perm_async(perm, bucket_id).get_result()
def filter_buckets_by_perm(perm, bucket_ids):
"""Filters given buckets keeping only ones the caller has the permission in.
Note that this function is not async!
Args:
perm: an instance of auth.Permission.
bucket_ids: an iterable with bucket IDs.
Returns:
A set of bucket IDs.
"""
pairs = utils.async_apply(
bucket_ids if isinstance(bucket_ids, set) else set(bucket_ids),
lambda bid: has_perm_async(perm, bid),
unordered=True,
)
return {bid for bid, has_perm in pairs if has_perm}
def buckets_by_perm_async(perm):
"""Returns buckets that the caller has the given permission in.
Results are memcached for 10 minutes per (identity, perm) pair.
Args:
perm: an instance of auth.Permission.
Returns:
A set of bucket IDs.
"""
assert isinstance(perm, auth.Permission), perm
assert perm in ALL_PERMISSIONS, perm
identity = auth.get_current_identity()
identity_str = identity.to_bytes()
@ndb.tasklet
def impl():
ctx = ndb.get_context()
cache_key = 'buckets_by_perm/%s/%s' % (identity_str, perm)
matching_buckets = yield ctx.memcache_get(cache_key)
if matching_buckets is not None:
raise ndb.Return(matching_buckets)
logging.info('Computing a set of buckets %r has %r in', identity_str, perm)
all_buckets = yield config.get_all_bucket_ids_async()
per_bucket = yield [has_perm_async(perm, bid) for bid in all_buckets]
matching_buckets = {bid for bid, has in zip(all_buckets, per_bucket) if has}
# Cache for 10 min
yield ctx.memcache_set(cache_key, matching_buckets, 10 * 60)
raise ndb.Return(matching_buckets)
return _get_or_create_cached_future(
identity, 'buckets_by_perm/%s' % perm, impl
)
################################################################################
## Role definitions (DEPRECATED).
class Action(messages.Enum):
# Schedule a build.
ADD_BUILD = 1
# Get information about a build.
VIEW_BUILD = 2
# Lease a build for execution. Normally done by build systems.
LEASE_BUILD = 3
# Cancel an existing build. Does not require a lease key.
CANCEL_BUILD = 4
# Unlease and reset state of an existing build. Normally done by admins.
RESET_BUILD = 5
# Search for builds or get a list of scheduled builds.
SEARCH_BUILDS = 6
# Delete all scheduled builds from a bucket.
DELETE_SCHEDULED_BUILDS = 9
# Know about bucket existence and read its info.
ACCESS_BUCKET = 10
# Pause builds for a given bucket.
PAUSE_BUCKET = 11
# Set the number for the next build in a builder.
SET_NEXT_NUMBER = 12
# Maps an Action to a description.
ACTION_DESCRIPTIONS = {
Action.ADD_BUILD:
'Schedule a build.',
Action.VIEW_BUILD:
'Get information about a build.',
Action.LEASE_BUILD:
'Lease a build for execution.',
Action.CANCEL_BUILD:
'Cancel an existing build. Does not require a lease key.',
Action.RESET_BUILD:
'Unlease and reset state of an existing build.',
Action.SEARCH_BUILDS:
'Search for builds or get a list of scheduled builds.',
Action.DELETE_SCHEDULED_BUILDS:
'Delete all scheduled builds from a bucket.',
Action.ACCESS_BUCKET:
'Know about a bucket\'s existence and read its info.',
Action.PAUSE_BUCKET:
'Pause builds for a given bucket.',
Action.SET_NEXT_NUMBER:
'Set the number for the next build in a builder.',
}
# Maps an Action to a permission, assuming Access API is used only by Milo.
ACTION_TO_PERM = {
Action.ADD_BUILD:
PERM_BUILDS_ADD,
Action.VIEW_BUILD:
PERM_BUILDS_GET,
Action.LEASE_BUILD:
PERM_BUILDS_LEASE,
Action.CANCEL_BUILD:
PERM_BUILDS_CANCEL,
Action.RESET_BUILD:
PERM_BUILDS_RESET,
Action.SEARCH_BUILDS:
PERM_BUILDS_LIST,
Action.DELETE_SCHEDULED_BUILDS:
PERM_BUCKETS_DELETE_BUILDS,
# Milo checks ACCESS_BUCKET exclusively to test visibility of builders.
Action.ACCESS_BUCKET:
PERM_BUILDERS_GET,
Action.PAUSE_BUCKET:
PERM_BUCKETS_PAUSE,
Action.SET_NEXT_NUMBER:
PERM_BUILDERS_SET_NUM,
}
# Reverse, since it is more useful in the actual implementation.
PERM_TO_ACTION = {perm: action for action, perm in ACTION_TO_PERM.items()}
################################################################################
## Granular actions. API uses these.
@ndb.tasklet
def can_update_build_async(): # pragma: no cover
"""Returns if the current identity is whitelisted to update builds."""
# TODO(crbug.com/1091604): Implementing using has_perm_async.
raise ndb.Return(auth.is_group_member(UPDATE_BUILD_ALLOWED_USERS))
################################################################################
## Implementation.
def get_role_async_deprecated(bucket_id):
"""Returns the most permissive role of the current user in |bucket_id|.
The most permissive role is the role that allows most actions, e.g. WRITER
is more permissive than READER.
Returns None if there's no such bucket or the current identity has no roles in
it at all.
"""
config.validate_bucket_id(bucket_id)
identity = auth.get_current_identity()
identity_str = identity.to_bytes()
@ndb.tasklet
def impl():
ctx = ndb.get_context()
cache_key = 'role/%s/%s' % (identity_str, bucket_id)
cache = yield ctx.memcache_get(cache_key)
if cache is not None:
raise ndb.Return(cache[0])
_, bucket_cfg = yield config.get_bucket_async(bucket_id)
if not bucket_cfg:
raise ndb.Return(None)
if auth.is_admin(identity):
raise ndb.Return(project_config_pb2.Acl.WRITER)
# A LUCI service calling us in the context of some project is allowed to
# do anything it wants in that project. We trust all LUCI services to do
# authorization on their own for this case. A cross-project request must be
# explicitly authorized in Buildbucket ACLs though (so we proceed to the
# bucket_cfg check below).
if identity.is_project:
project_id, _ = config.parse_bucket_id(bucket_id)
if project_id == identity.name:
raise ndb.Return(project_config_pb2.Acl.WRITER)
# Roles are just numbers. The higher the number, the more permissions
# the identity has. We exploit this here to get the single maximally
# permissive role for the current identity.
role = None
for rule in bucket_cfg.acls:
if rule.role <= role:
continue
if (rule.identity == identity_str or
(rule.group and auth.is_group_member(rule.group, identity))):
role = rule.role
yield ctx.memcache_set(cache_key, (role,), time=60)
raise ndb.Return(role)
return _get_or_create_cached_future(identity, 'role/%s' % bucket_id, impl)
@ndb.tasklet
def permitted_actions_async(bucket_id):
"""Returns a tuple of actions (as Action enums) permitted to the caller."""
per_perm = yield [has_perm_async(perm, bucket_id) for perm in PERM_TO_ACTION]
actions = [
PERM_TO_ACTION[perm] for perm, has in zip(PERM_TO_ACTION, per_perm) if has
]
raise ndb.Return(tuple(sorted(actions)))
@utils.cache
def self_identity(): # pragma: no cover
"""Returns identity of the buildbucket app."""
return auth.Identity('user', app_identity.get_service_account_name())
def delegate_async(target_service_host, identity=None, tag=''):
"""Mints a delegation token for the current identity."""
tag = tag or ''
identity = identity or auth.get_current_identity()
# TODO(vadimsh): 'identity' here can be 'project:<...>' and we happily create
# a delegation token for it, which is weird. Buildbucket should call Swarming
# using 'project:<...>' identity directly, not through a delegation token.
def impl():
return auth.delegate_async(
audience=[self_identity()],
services=['https://%s' % target_service_host],
impersonate=identity,
tags=[tag] if tag else [],
)
return _get_or_create_cached_future(
identity, 'delegation_token:%s:%s' % (target_service_host, tag), impl
)
def current_identity_cannot(action_format, *args): # pragma: no cover
"""Returns AuthorizationError."""
action = action_format % args
msg = 'User %s cannot %s' % (auth.get_current_identity().to_bytes(), action)
logging.warning(msg)
return auth.AuthorizationError(msg)
def parse_identity(identity):
"""Parses an identity string if it is a string."""
if isinstance(identity, basestring):
if not identity: # pragma: no cover
return None
if ':' not in identity: # pragma: no branch
identity = 'user:%s' % identity
try:
identity = auth.Identity.from_bytes(identity)
except ValueError as ex:
raise errors.InvalidInputError('Invalid identity: %s' % ex)
return identity
_thread_local = threading.local()
def _get_or_create_cached_future(identity, key, create_future):
"""Returns a future cached in the current GAE request context.
Uses the pair (identity, key) as the caching key.
Using this function may cause RuntimeError with a deadlock if the returned
future is not waited for before leaving an ndb context, but that's a bug
in the first place.
"""
assert isinstance(identity, auth.Identity), identity
full_key = (identity, key)
# Docs:
# https://cloud.google.com/appengine/docs/standard/python/how-requests-are-handled#request-ids
req_id = os.environ['REQUEST_LOG_ID']
cache = getattr(_thread_local, 'request_cache', {})
if cache.get('request_id') != req_id:
cache = {
'request_id': req_id,
'futures': {},
}
_thread_local.request_cache = cache
fut_entry = cache['futures'].get(full_key)
if fut_entry is None:
fut_entry = {
'future': create_future(),
'ndb_context': ndb.get_context(),
}
cache['futures'][full_key] = fut_entry
assert (
fut_entry['future'].done() or
ndb.get_context() is fut_entry['ndb_context']
)
return fut_entry['future']
def clear_request_cache():
_thread_local.request_cache = {}