diff --git a/etc/heat-api-paste.ini b/etc/heat-api-paste.ini index 0b3adfef0d..3a03fbd523 100644 --- a/etc/heat-api-paste.ini +++ b/etc/heat-api-paste.ini @@ -1,6 +1,7 @@ -# Default minimal pipeline + +# Default pipeline [pipeline:heat-api] -pipeline = versionnegotiation context apiv1app +pipeline = versionnegotiation ec2authtoken authtoken context apiv1app # Use the following pipeline for keystone auth # i.e. in heat-api.conf: @@ -8,7 +9,7 @@ pipeline = versionnegotiation context apiv1app # flavor = keystone # [pipeline:heat-api-keystone] -pipeline = versionnegotiation authtoken auth-context apiv1app +pipeline = versionnegotiation ec2authtoken authtoken context apiv1app # Use the following pipeline to enable transparent caching of image files # i.e. in heat-api.conf: @@ -16,7 +17,7 @@ pipeline = versionnegotiation authtoken auth-context apiv1app # flavor = caching # [pipeline:heat-api-caching] -pipeline = versionnegotiation context cache apiv1app +pipeline = versionnegotiation ec2authtoken authtoken context cache apiv1app # Use the following pipeline for keystone auth with caching # i.e. in heat-api.conf: @@ -24,7 +25,7 @@ pipeline = versionnegotiation context cache apiv1app # flavor = keystone+caching # [pipeline:heat-api-keystone+caching] -pipeline = versionnegotiation authtoken auth-context cache apiv1app +pipeline = versionnegotiation ec2authtoken authtoken context cache apiv1app # Use the following pipeline to enable the Image Cache Management API # i.e. in heat-api.conf: @@ -32,7 +33,7 @@ pipeline = versionnegotiation authtoken auth-context cache apiv1app # flavor = cachemanagement # [pipeline:heat-api-cachemanagement] -pipeline = versionnegotiation context cache cachemanage apiv1app +pipeline = versionnegotiation ec2authtoken authtoken context cache cachemanage apiv1app # Use the following pipeline for keystone auth with cache management # i.e. in heat-api.conf: @@ -40,7 +41,7 @@ pipeline = versionnegotiation context cache cachemanage apiv1app # flavor = keystone+cachemanagement # [pipeline:heat-api-keystone+cachemanagement] -pipeline = versionnegotiation authtoken auth-context cache cachemanage apiv1app +pipeline = versionnegotiation ec2authtoken authtoken auth-context cache cachemanage apiv1app [app:apiv1app] paste.app_factory = heat.common.wsgi:app_factory @@ -62,18 +63,27 @@ heat.filter_factory = heat.api.middleware.cache_manage:CacheManageFilter paste.filter_factory = heat.common.wsgi:filter_factory heat.filter_factory = heat.common.context:ContextMiddleware +[filter:ec2authtoken] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.v1:EC2Token +auth_uri = http://127.0.0.1:5000/v2.0 +keystone_ec2_uri = http://localhost:5000/v2.0/ec2tokens + [filter:authtoken] -paste.filter_factory = keystone.middleware.auth_token:filter_factory +paste.filter_factory = heat.common.auth_token:filter_factory service_protocol = http service_host = 127.0.0.1 service_port = 5000 auth_host = 127.0.0.1 auth_port = 35357 auth_protocol = http -auth_uri = http://127.0.0.1:5000/ -admin_tenant_name = %SERVICE_TENANT_NAME% -admin_user = %SERVICE_USER% -admin_password = %SERVICE_PASSWORD% +auth_uri = http://127.0.0.1:5000/v2.0 + +# These must be set to your local values in order for the token +# authentication to work. +admin_tenant_name = admin +admin_user = admin +admin_password = verybadpass [filter:auth-context] paste.filter_factory = heat.common.wsgi:filter_factory diff --git a/heat/api/v1/__init__.py b/heat/api/v1/__init__.py index 3cf5a81443..cc4d94eac4 100644 --- a/heat/api/v1/__init__.py +++ b/heat/api/v1/__init__.py @@ -13,6 +13,9 @@ # License for the specific language governing permissions and limitations # under the License. +import json +import urlparse +import httplib import logging import routes import gettext @@ -23,10 +26,89 @@ from heat.api.v1 import stacks from heat.common import wsgi from webob import Request +import webob +from heat import utils +from heat.common import context logger = logging.getLogger(__name__) +class EC2Token(wsgi.Middleware): + """Authenticate an EC2 request with keystone and convert to token.""" + + def __init__(self, app, conf, **local_conf): + self.conf = local_conf + self.application = app + + @webob.dec.wsgify(RequestClass=wsgi.Request) + def __call__(self, req): + # Read request signature and access id. + logger.info("Checking AWS credentials..") + try: + signature = req.params['Signature'] + access = req.params['AWSAccessKeyId'] + except KeyError: + # We ignore a key error here so that we can use both + # authentication methods. Returning here just means + # the user didn't supply AWS authentication and we'll let + # the app try native keystone next. + logger.info("No AWS credentials found.") + return self.application + + logger.info("AWS credentials found, checking against keystone.") + # Make a copy of args for authentication and signature verification. + auth_params = dict(req.params) + # Not part of authentication args + auth_params.pop('Signature') + + # Authenticate the request. + creds = {'ec2Credentials': {'access': access, + 'signature': signature, + 'host': req.host, + 'verb': req.method, + 'path': req.path, + 'params': auth_params, + }} + creds_json = None + try: + creds_json = json.dumps(creds) + except TypeError: + creds_json = json.dumps(to_primitive(creds)) + headers = {'Content-Type': 'application/json'} + + # Disable 'has no x member' pylint error + # for httplib and urlparse + # pylint: disable-msg=E1101 + + logger.info('Authenticating with %s' % self.conf['keystone_ec2_uri']) + o = urlparse.urlparse(self.conf['keystone_ec2_uri']) + if o.scheme == 'http': + conn = httplib.HTTPConnection(o.netloc) + else: + conn = httplib.HTTPSConnection(o.netloc) + conn.request('POST', o.path, body=creds_json, headers=headers) + response = conn.getresponse().read() + conn.close() + + # NOTE(vish): We could save a call to keystone by + # having keystone return token, tenant, + # user, and roles from this call. + + result = json.loads(response) + try: + token_id = result['access']['token']['id'] + logger.info("AWS authentication successful.") + except (AttributeError, KeyError): + # FIXME: Should be 404 I think. + logger.info("AWS authentication failure.") + raise webob.exc.HTTPBadRequest() + + # Authenticated! + req.headers['X-Auth-Token'] = token_id + req.headers['X-Auth-URL'] = self.conf['auth_uri'] + return self.application + + class API(wsgi.Router): """ diff --git a/heat/api/v1/stacks.py b/heat/api/v1/stacks.py index 5e5e04dc02..310cf90b2c 100644 --- a/heat/api/v1/stacks.py +++ b/heat/api/v1/stacks.py @@ -29,8 +29,9 @@ from webob.exc import (HTTPNotFound, HTTPBadRequest) from heat.common import wsgi from heat.common import config +from heat.common import context +from heat import utils from heat import rpc -from heat import context import heat.rpc.common as rpc_common @@ -51,10 +52,12 @@ class StackController(object): """ Returns the following information for all stacks: """ - con = context.get_admin_context() + con = req.context + parms = dict(req.params) + stack_list = rpc.call(con, 'engine', {'method': 'list_stacks', - 'args': {'params': dict(req.params)}}) + 'args': {'params': parms}}) res = {'ListStacksResponse': { 'ListStacksResult': {'StackSummaries': []}}} @@ -70,13 +73,14 @@ class StackController(object): """ Returns the following information for all stacks: """ - con = context.get_admin_context() + con = req.context + parms = dict(req.params) try: stack_list = rpc.call(con, 'engine', {'method': 'show_stack', 'args': {'stack_name': req.params['StackName'], - 'params': dict(req.params)}}) + 'params': parms}}) except rpc_common.RemoteError as ex: return webob.exc.HTTPBadRequest(str(ex)) @@ -116,7 +120,8 @@ class StackController(object): """ Returns the following information for all stacks: """ - con = context.get_admin_context() + con = req.context + parms = dict(req.params) try: templ = self._get_template(req) @@ -139,13 +144,14 @@ class StackController(object): {'method': 'create_stack', 'args': {'stack_name': req.params['StackName'], 'template': stack, - 'params': dict(req.params)}}) + 'params': parms}}) except rpc_common.RemoteError as ex: return webob.exc.HTTPBadRequest(str(ex)) def validate_template(self, req): - con = context.get_admin_context() + con = req.context + parms = dict(req.params) try: templ = self._get_template(req) @@ -167,7 +173,7 @@ class StackController(object): return rpc.call(con, 'engine', {'method': 'validate_template', 'args': {'template': stack, - 'params': dict(req.params)}}) + 'params': parms}}) except rpc_common.RemoteError as ex: return webob.exc.HTTPBadRequest(str(ex)) @@ -175,13 +181,14 @@ class StackController(object): """ Returns the following information for all stacks: """ - con = context.get_admin_context() + con = req.context + parms = dict(req.params) try: res = rpc.call(con, 'engine', {'method': 'delete_stack', 'args': {'stack_name': req.params['StackName'], - 'params': dict(req.params)}}) + 'params': parms}}) except rpc_common.RemoteError as ex: return webob.exc.HTTPBadRequest(str(ex)) @@ -195,12 +202,15 @@ class StackController(object): """ Returns the following information for all stacks: """ - con = context.get_admin_context() + con = req.context + parms = dict(req.params) + stack_name = req.params.get('StackName', None) try: event_res = rpc.call(con, 'engine', {'method': 'list_events', - 'args': {'stack_name': stack_name}}) + 'args': {'stack_name': stack_name, + 'params': parms}}) except rpc_common.RemoteError as ex: return webob.exc.HTTPBadRequest(str(ex)) diff --git a/heat/common/auth_token.py b/heat/common/auth_token.py new file mode 100644 index 0000000000..7d8bbb6075 --- /dev/null +++ b/heat/common/auth_token.py @@ -0,0 +1,534 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2012 OpenStack LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# 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. + +""" +TOKEN-BASED AUTH MIDDLEWARE + +This WSGI component: + +* Verifies that incoming client requests have valid tokens by validating + tokens with the auth service. +* Rejects unauthenticated requests UNLESS it is in 'delay_auth_decision' + mode, which means the final decision is delegated to the downstream WSGI + component (usually the OpenStack service) +* Collects and forwards identity information based on a valid token + such as user name, tenant, etc + +Refer to: http://keystone.openstack.org/middlewarearchitecture.html + +HEADERS +------- + +* Headers starting with HTTP\_ is a standard http header +* Headers starting with HTTP_X is an extended http header + +Coming in from initial call from client or customer +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +HTTP_X_AUTH_TOKEN + The client token being passed in. + +HTTP_X_STORAGE_TOKEN + The client token being passed in (legacy Rackspace use) to support + swift/cloud files + +Used for communication between components +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +WWW-Authenticate + HTTP header returned to a user indicating which endpoint to use + to retrieve a new token + +What we add to the request for use by the OpenStack service +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +HTTP_X_IDENTITY_STATUS + 'Confirmed' or 'Invalid' + The underlying service will only see a value of 'Invalid' if the Middleware + is configured to run in 'delay_auth_decision' mode + +HTTP_X_TENANT_ID + Identity service managed unique identifier, string + +HTTP_X_TENANT_NAME + Unique tenant identifier, string + +HTTP_X_USER_ID + Identity-service managed unique identifier, string + +HTTP_X_USER_NAME + Unique user identifier, string + +HTTP_X_ROLES + Comma delimited list of case-sensitive Roles + +HTTP_X_TENANT + *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME + Keystone-assigned unique identifier, deprecated + +HTTP_X_USER + *Deprecated* in favor of HTTP_X_USER_ID and HTTP_X_USER_NAME + Unique user name, string + +HTTP_X_ROLE + *Deprecated* in favor of HTTP_X_ROLES + This is being renamed, and the new header contains the same data. + +""" + +import httplib +import json +import logging +import time + +import webob +import webob.exc + + +LOG = logging.getLogger(__name__) + + +class InvalidUserToken(Exception): + pass + + +class ServiceError(Exception): + pass + + +class AuthProtocol(object): + """Auth Middleware that handles authenticating client calls.""" + + def __init__(self, app, conf): + LOG.info('Starting keystone auth_token middleware') + self.conf = conf + self.app = app + + # delay_auth_decision means we still allow unauthenticated requests + # through and we let the downstream service make the final decision + self.delay_auth_decision = int(conf.get('delay_auth_decision', 0)) + + # where to find the auth service (we use this to validate tokens) + self.auth_host = conf.get('auth_host') + self.auth_port = int(conf.get('auth_port', 35357)) + auth_protocol = conf.get('auth_protocol', 'https') + if auth_protocol == 'http': + self.http_client_class = httplib.HTTPConnection + else: + self.http_client_class = httplib.HTTPSConnection + + default_auth_uri = '%s://%s:%s' % (auth_protocol, + self.auth_host, + self.auth_port) + self.auth_uri = conf.get('auth_uri', default_auth_uri) + + # Credentials used to verify this component with the Auth service since + # validating tokens is a privileged call + self.admin_token = conf.get('admin_token') + self.admin_user = conf.get('admin_user') + self.admin_password = conf.get('admin_password') + self.admin_tenant_name = conf.get('admin_tenant_name', 'admin') + + # Token caching via memcache + self._cache = None + self._iso8601 = None + memcache_servers = conf.get('memcache_servers') + # By default the token will be cached for 5 minutes + self.token_cache_time = conf.get('token_cache_time', 300) + if memcache_servers: + try: + import memcache + import iso8601 + LOG.info('Using memcache for caching token') + self._cache = memcache.Client(memcache_servers.split(',')) + self._iso8601 = iso8601 + except NameError as e: + LOG.warn('disabled caching due to missing libraries %s', e) + + def __call__(self, env, start_response): + """Handle incoming request. + + Authenticate send downstream on success. Reject request if + we can't authenticate. + + """ + LOG.debug('Authenticating user token') + try: + self._remove_auth_headers(env) + user_token = self._get_user_token_from_header(env) + token_info = self._validate_user_token(user_token) + user_headers = self._build_user_headers(token_info) + self._add_headers(env, user_headers) + return self.app(env, start_response) + + except InvalidUserToken: + if self.delay_auth_decision: + LOG.info('Invalid user token - deferring reject downstream') + self._add_headers(env, {'X-Identity-Status': 'Invalid'}) + return self.app(env, start_response) + else: + LOG.info('Invalid user token - rejecting request') + return self._reject_request(env, start_response) + + except ServiceError, e: + LOG.critical('Unable to obtain admin token: %s' % e) + resp = webob.exc.HTTPServiceUnavailable() + return resp(env, start_response) + + def _remove_auth_headers(self, env): + """Remove headers so a user can't fake authentication. + + :param env: wsgi request environment + + """ + auth_headers = ( + 'X-Identity-Status', + 'X-Tenant-Id', + 'X-Tenant-Name', + 'X-User-Id', + 'X-User-Name', + 'X-Roles', + # Deprecated + 'X-User', + 'X-Tenant', + 'X-Role', + ) + LOG.debug('Removing headers from request environment: %s' % + ','.join(auth_headers)) + self._remove_headers(env, auth_headers) + + def _get_user_token_from_header(self, env): + """Get token id from request. + + :param env: wsgi request environment + :return token id + :raises InvalidUserToken if no token is provided in request + + """ + token = self._get_header(env, 'X-Auth-Token', + self._get_header(env, 'X-Storage-Token')) + if token: + return token + else: + LOG.warn("Unable to find authentication token in headers: %s", env) + raise InvalidUserToken('Unable to find token in headers') + + def _reject_request(self, env, start_response): + """Redirect client to auth server. + + :param env: wsgi request environment + :param start_response: wsgi response callback + :returns HTTPUnauthorized http response + + """ + headers = [('WWW-Authenticate', 'Keystone uri=\'%s\'' % self.auth_uri)] + resp = webob.exc.HTTPUnauthorized('Authentication required', headers) + return resp(env, start_response) + + def get_admin_token(self): + """Return admin token, possibly fetching a new one. + + :return admin token id + :raise ServiceError when unable to retrieve token from keystone + + """ + if not self.admin_token: + self.admin_token = self._request_admin_token() + + return self.admin_token + + def _get_http_connection(self): + return self.http_client_class(self.auth_host, self.auth_port) + + def _json_request(self, method, path, body=None, additional_headers=None): + """HTTP request helper used to make json requests. + + :param method: http method + :param path: relative request url + :param body: dict to encode to json as request body. Optional. + :param additional_headers: dict of additional headers to send with + http request. Optional. + :return (http response object, response body parsed as json) + :raise ServerError when unable to communicate with keystone + + """ + conn = self._get_http_connection() + + kwargs = { + 'headers': { + 'Content-type': 'application/json', + 'Accept': 'application/json', + }, + } + + if additional_headers: + kwargs['headers'].update(additional_headers) + + if body: + kwargs['body'] = json.dumps(body) + + try: + conn.request(method, path, **kwargs) + response = conn.getresponse() + body = response.read() + except Exception, e: + LOG.error('HTTP connection exception: %s' % e) + raise ServiceError('Unable to communicate with keystone') + finally: + conn.close() + + try: + data = json.loads(body) + except ValueError: + LOG.debug('Keystone did not return json-encoded body') + data = {} + + return response, data + + def _request_admin_token(self): + """Retrieve new token as admin user from keystone. + + :return token id upon success + :raises ServerError when unable to communicate with keystone + + """ + params = { + 'auth': { + 'passwordCredentials': { + 'username': self.admin_user, + 'password': self.admin_password, + }, + 'tenantName': self.admin_tenant_name, + } + } + + response, data = self._json_request('POST', + '/v2.0/tokens', + body=params) + + try: + token = data['access']['token']['id'] + assert token + return token + except (AssertionError, KeyError): + LOG.warn("Unexpected response from keystone service: %s", data) + raise ServiceError('invalid json response') + + def _validate_user_token(self, user_token, retry=True): + """Authenticate user token with keystone. + + :param user_token: user's token id + :param retry: flag that forces the middleware to retry + user authentication when an indeterminate + response is received. Optional. + :return token object received from keystone on success + :raise InvalidUserToken if token is rejected + :raise ServiceError if unable to authenticate token + + """ + cached = self._cache_get(user_token) + if cached: + return cached + + headers = {'X-Auth-Token': self.get_admin_token()} + response, data = self._json_request('GET', + '/v2.0/tokens/%s' % user_token, + additional_headers=headers) + + if response.status == 200: + self._cache_put(user_token, data) + return data + if response.status == 404: + # FIXME(ja): I'm assuming the 404 status means that user_token is + # invalid - not that the admin_token is invalid + self._cache_store_invalid(user_token) + LOG.warn("Authorization failed for token %s", user_token) + raise InvalidUserToken('Token authorization failed') + if response.status == 401: + LOG.info('Keystone rejected admin token %s, resetting', headers) + self.admin_token = None + else: + LOG.error('Bad response code while validating token: %s' % + response.status) + if retry: + LOG.info('Retrying validation') + return self._validate_user_token(user_token, False) + else: + LOG.warn("Invalid user token: %s. Keystone response: %s.", + user_token, data) + + raise InvalidUserToken() + + def _build_user_headers(self, token_info): + """Convert token object into headers. + + Build headers that represent authenticated user: + * X_IDENTITY_STATUS: Confirmed or Invalid + * X_TENANT_ID: id of tenant if tenant is present + * X_TENANT_NAME: name of tenant if tenant is present + * X_USER_ID: id of user + * X_USER_NAME: name of user + * X_ROLES: list of roles + + Additional (deprecated) headers include: + * X_USER: name of user + * X_TENANT: For legacy compatibility before we had ID and Name + * X_ROLE: list of roles + + :param token_info: token object returned by keystone on authentication + :raise InvalidUserToken when unable to parse token object + + """ + user = token_info['access']['user'] + token = token_info['access']['token'] + roles = ','.join([role['name'] for role in user.get('roles', [])]) + + def get_tenant_info(): + """Returns a (tenant_id, tenant_name) tuple from context.""" + def essex(): + """Essex puts the tenant ID and name on the token.""" + return (token['tenant']['id'], token['tenant']['name']) + + def pre_diablo(): + """Pre-diablo, Keystone only provided tenantId.""" + return (token['tenantId'], token['tenantId']) + + def default_tenant(): + """Assume the user's default tenant.""" + return (user['tenantId'], user['tenantName']) + + for method in [essex, pre_diablo, default_tenant]: + try: + return method() + except KeyError: + pass + + raise InvalidUserToken('Unable to determine tenancy.') + + tenant_id, tenant_name = get_tenant_info() + + user_id = user['id'] + user_name = user['name'] + + return { + 'X-Identity-Status': 'Confirmed', + 'X-Tenant-Id': tenant_id, + 'X-Tenant-Name': tenant_name, + 'X-User-Id': user_id, + 'X-User-Name': user_name, + 'X-Roles': roles, + # Deprecated + 'X-User': user_name, + 'X-Tenant': tenant_name, + 'X-Role': roles, + 'X-Admin-User': self.admin_user, + 'X-Admin-Pass': self.admin_password, + 'X-Auth-Url': self.conf['auth_uri'], + } + + def _header_to_env_var(self, key): + """Convert header to wsgi env variable. + + :param key: http header name (ex. 'X-Auth-Token') + :return wsgi env variable name (ex. 'HTTP_X_AUTH_TOKEN') + + """ + return 'HTTP_%s' % (key.replace('-', '_').upper()) + + def _add_headers(self, env, headers): + """Add http headers to environment.""" + for (k, v) in headers.iteritems(): + env_key = self._header_to_env_var(k) + env[env_key] = v + + def _remove_headers(self, env, keys): + """Remove http headers from environment.""" + for k in keys: + env_key = self._header_to_env_var(k) + try: + del env[env_key] + except KeyError: + pass + + def _get_header(self, env, key, default=None): + """Get http header from environment.""" + env_key = self._header_to_env_var(key) + return env.get(env_key, default) + + def _cache_get(self, token): + """Return token information from cache. + + If token is invalid raise InvalidUserToken + return token only if fresh (not expired). + """ + if self._cache and token: + key = 'tokens/%s' % token + cached = self._cache.get(key) + if cached == 'invalid': + LOG.debug('Cached Token %s is marked unauthorized', token) + raise InvalidUserToken('Token authorization failed') + if cached: + data, expires = cached + if time.time() < float(expires): + LOG.debug('Returning cached token %s', token) + return data + else: + LOG.debug('Cached Token %s seems expired', token) + + def _cache_put(self, token, data): + """Put token data into the cache. + + Stores the parsed expire date in cache allowing + quick check of token freshness on retrieval. + """ + if self._cache and data: + key = 'tokens/%s' % token + if 'token' in data.get('access', {}): + timestamp = data['access']['token']['expires'] + expires = self._iso8601.parse_date(timestamp).strftime('%s') + else: + LOG.error('invalid token format') + return + LOG.debug('Storing %s token in memcache', token) + self._cache.set(key, + (data, expires), + time=self.token_cache_time) + + def _cache_store_invalid(self, token): + """Store invalid token in cache.""" + if self._cache: + key = 'tokens/%s' % token + LOG.debug('Marking token %s as unauthorized in memcache', token) + self._cache.set(key, + 'invalid', + time=self.token_cache_time) + + +def filter_factory(global_conf, **local_conf): + """Returns a WSGI filter app for use with paste.deploy.""" + conf = global_conf.copy() + conf.update(local_conf) + + def auth_filter(app): + return AuthProtocol(app, conf) + return auth_filter + + +def app_factory(global_conf, **local_conf): + conf = global_conf.copy() + conf.update(local_conf) + return AuthProtocol(None, conf) diff --git a/heat/common/context.py b/heat/common/context.py index abedbc06b0..b815a9143f 100644 --- a/heat/common/context.py +++ b/heat/common/context.py @@ -13,10 +13,16 @@ # License for the specific language governing permissions and limitations # under the License. +from heat.openstack.common import local from heat.common import exception from heat.common import wsgi from heat.openstack.common import cfg from heat.openstack.common import importutils +from heat.common import utils as heat_utils + + +def generate_request_id(): + return 'req-' + str(heat_utils.gen_uuid()) class RequestContext(object): @@ -25,17 +31,48 @@ class RequestContext(object): accesses the system, as well as additional request information. """ - def __init__(self, auth_tok=None, user=None, tenant=None, roles=None, + def __init__(self, auth_token=None, username=None, password=None, + tenant=None, tenant_id=None, auth_url=None, roles=None, is_admin=False, read_only=False, show_deleted=False, - owner_is_tenant=True): - self.auth_tok = auth_tok - self.user = user + owner_is_tenant=True, overwrite=True, **kwargs): + """ + :param overwrite: Set to False to ensure that the greenthread local + copy of the index is not overwritten. + + :param kwargs: Extra arguments that might be present, but we ignore + because they possibly came in from older rpc messages. + """ + + self.auth_token = auth_token + self.username = username + self.password = password self.tenant = tenant + self.tenant_id = tenant_id + self.auth_url = auth_url self.roles = roles or [] self.is_admin = is_admin self.read_only = read_only self._show_deleted = show_deleted self.owner_is_tenant = owner_is_tenant + if overwrite or not hasattr(local.store, 'context'): + self.update_store() + + def update_store(self): + local.store.context = self + + def to_dict(self): + return {'auth_token': self.auth_token, + 'username': self.username, + 'password': self.password, + 'tenant': self.tenant, + 'tenant_id': self.tenant_id, + 'auth_url': self.auth_url, + 'roles': self.roles, + 'is_admin': self.is_admin} + + @classmethod + def from_dict(cls, values): + return cls(**values) @property def owner(self): @@ -50,6 +87,10 @@ class RequestContext(object): return False +def get_admin_context(read_deleted="no"): + return RequestContext(is_admin=True) + + class ContextMiddleware(wsgi.Middleware): opts = [ @@ -93,30 +134,33 @@ class ContextMiddleware(wsgi.Middleware): tokenauth middleware would have rejected the request, so we must be using NoAuth. In that case, assume that is_admin=True. """ - auth_tok = req.headers.get('X-Auth-Token') - # - # hack alert, this is for POC only FIXME properly! - # - if False: - if req.headers.get('X-Identity-Status') == 'Confirmed': - # 1. Auth-token is passed, check other headers - user = req.headers.get('X-User-Id') - tenant = req.headers.get('X-Tenant-Id') - roles = [r.strip() - for r in req.headers.get('X-Roles', '').split(',')] - is_admin = self.conf.admin_role in roles - else: - # 2. Indentity-Status not confirmed - # FIXME(sirp): not sure what the correct behavior in this case - # is; just raising NotAuthenticated for now - raise exception.NotAuthenticated() - else: - # 3. Auth-token is ommited, assume NoAuth - user = None - tenant = None - roles = [] - is_admin = True + headers = req.headers - req.context = self.make_context( - auth_tok=auth_tok, user=user, tenant=tenant, roles=roles, - is_admin=is_admin) + try: + """ + This sets the username/password to the admin user because you + need this information in order to perform token authentication. + The real 'username' is the 'tenant'. + + We should also check here to see if X-Auth-Token is not set and + in that case we should assign the user/pass directly as the real + username/password and token as None. 'tenant' should still be + the username. + """ + + token = headers.get('X-Auth-Token') + username = headers.get('X-Admin-User') + password = headers.get('X-Admin-Pass') + tenant = headers.get('X-Tenant') + tenant_id = headers.get('X-Tenant-Id') + auth_url = headers.get('X-Auth-Url') + roles = headers.get('X-Roles') + except: + raise exception.NotAuthenticated() + + req.context = self.make_context(auth_token=token, + tenant=tenant, tenant_id=tenant_id, + username=username, + password=password, + auth_url=auth_url, roles=roles, + is_admin=True) diff --git a/heat/context.py b/heat/context.py deleted file mode 100644 index a160820c3d..0000000000 --- a/heat/context.py +++ /dev/null @@ -1,125 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2011 OpenStack LLC. -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# 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. - -"""RequestContext: context for requests that persist through all of nova.""" - -import copy -import logging - -from heat.openstack.common import local -from heat.openstack.common import utils -from heat.openstack.common import timeutils - -from heat.common import utils as heat_utils - -LOG = logging.getLogger(__name__) - - -def generate_request_id(): - return 'req-' + str(heat_utils.gen_uuid()) - - -class RequestContext(object): - """Security context and request information. - - Represents the user taking a given action within the system. - - """ - - def __init__(self, user_id, project_id, is_admin=None, read_deleted="no", - roles=None, remote_address=None, timestamp=None, - request_id=None, auth_token=None, overwrite=True, **kwargs): - """ - :param read_deleted: 'no' indicates deleted records are hidden, 'yes' - indicates deleted records are visible, 'only' indicates that - *only* deleted records are visible. - - :param overwrite: Set to False to ensure that the greenthread local - copy of the index is not overwritten. - - :param kwargs: Extra arguments that might be present, but we ignore - because they possibly came in from older rpc messages. - """ - if read_deleted not in ('no', 'yes', 'only'): - raise ValueError(_("read_deleted can only be one of 'no', " - "'yes' or 'only', not %r") % read_deleted) - if kwargs: - LOG.warn(_('Arguments dropped when creating context: %s') % - str(kwargs)) - - self.user_id = user_id - self.project_id = project_id - self.roles = roles or [] - self.is_admin = is_admin - if self.is_admin is None: - self.is_admin = 'admin' in [x.lower() for x in self.roles] - elif self.is_admin and 'admin' not in self.roles: - self.roles.append('admin') - self.read_deleted = read_deleted - self.remote_address = remote_address - if not timestamp: - timestamp = timeutils.utcnow() - if isinstance(timestamp, basestring): - timestamp = heat_utils.parse_strtime(timestamp) - self.timestamp = timestamp - if not request_id: - request_id = generate_request_id() - self.request_id = request_id - self.auth_token = auth_token - if overwrite or not hasattr(local.store, 'context'): - self.update_store() - - def update_store(self): - local.store.context = self - - def to_dict(self): - return {'user_id': self.user_id, - 'project_id': self.project_id, - 'is_admin': self.is_admin, - 'read_deleted': self.read_deleted, - 'roles': self.roles, - 'remote_address': self.remote_address, - 'timestamp': heat_utils.strtime(self.timestamp), - 'request_id': self.request_id, - 'auth_token': self.auth_token} - - @classmethod - def from_dict(cls, values): - return cls(**values) - - def elevated(self, read_deleted=None, overwrite=False): - """Return a version of this context with admin flag set.""" - context = copy.copy(self) - context.is_admin = True - - if 'admin' not in context.roles: - context.roles.append('admin') - - if read_deleted is not None: - context.read_deleted = read_deleted - - return context - - -def get_admin_context(read_deleted="no"): - return RequestContext(user_id=None, - project_id=None, - is_admin=True, - read_deleted=read_deleted, - overwrite=False) diff --git a/heat/engine/manager.py b/heat/engine/manager.py index ce6919c9ed..4b22c8ed03 100644 --- a/heat/engine/manager.py +++ b/heat/engine/manager.py @@ -33,6 +33,10 @@ from heat.engine import resources from heat.db import api as db_api from heat.openstack.common import timeutils +from novaclient.v1_1 import client +from novaclient.exceptions import BadRequest +from novaclient.exceptions import NotFound + logger = logging.getLogger('heat.engine.manager') @@ -51,6 +55,21 @@ class EngineManager(manager.Manager): """Load configuration options and connect to the hypervisor.""" pass + def _authenticate(self, con): + """ Authenticate against the 'heat' service. This should be + the first call made in an endpoint call. I like to see this + done explicitly so that it is clear there is an authentication + request at the entry to the call. + """ + + nova = client.Client(con.username, con.password, + con.tenant, con.auth_url, + proxy_token=con.auth_token, + proxy_tenant_id=con.tenant_id, + service_type='heat', + service_name='heat') + nova.authenticate() + def list_stacks(self, context, params): """ The list_stacks method is the end point that actually implements @@ -58,13 +77,16 @@ class EngineManager(manager.Manager): arg1 -> RPC context. arg2 -> Dict of http request parameters passed in from API side. """ - logger.info('context is %s' % context) + + self._authenticate(context) + res = {'stacks': []} stacks = db_api.stack_get_all(None) if stacks == None: return res for s in stacks: - ps = parser.Stack(s.name, s.raw_template.parsed_template.template, + ps = parser.Stack(context, s.name, + s.raw_template.parsed_template.template, s.id, params) mem = {} mem['stack_id'] = s.id @@ -84,10 +106,13 @@ class EngineManager(manager.Manager): arg2 -> Name of the stack you want to see. arg3 -> Dict of http request parameters passed in from API side. """ + self._authenticate(context) + res = {'stacks': []} s = db_api.stack_get(None, stack_name) if s: - ps = parser.Stack(s.name, s.raw_template.parsed_template.template, + ps = parser.Stack(context, s.name, + s.raw_template.parsed_template.template, s.id, params) mem = {} mem['stack_id'] = s.id @@ -122,6 +147,9 @@ class EngineManager(manager.Manager): arg4 -> Params passed from API. """ logger.info('template is %s' % template) + + self._authenticate(context) + if db_api.stack_get(None, stack_name): return {'Error': 'Stack already exists with that name.'} @@ -129,8 +157,9 @@ class EngineManager(manager.Manager): # We don't want to reset the stack template, so we are making # an instance just for validation. template_copy = deepcopy(template) - stack_validator = parser.Stack(stack_name, template_copy, 0, params, - metadata_server=metadata_server) + stack_validator = parser.Stack(context, stack_name, + template_copy, 0, params, + metadata_server=metadata_server) response = stack_validator.validate() stack_validator = None template_copy = None @@ -138,7 +167,7 @@ class EngineManager(manager.Manager): response['ValidateTemplateResult']['Description']: return response['ValidateTemplateResult']['Description'] - stack = parser.Stack(stack_name, template, 0, params, + stack = parser.Stack(context, stack_name, template, 0, params, metadata_server=metadata_server) rt = {} rt['template'] = template @@ -172,13 +201,15 @@ class EngineManager(manager.Manager): arg4 -> Params passed from API. """ + self._authenticate(context) + logger.info('validate_template') if template is None: msg = _("No Template provided.") return webob.exc.HTTPBadRequest(explanation=msg) try: - s = parser.Stack('validate', template, 0, params) + s = parser.Stack(context, 'validate', template, 0, params) except KeyError: res = 'A Fn::FindInMap operation referenced'\ 'a non-existent map [%s]' % sys.exc_value @@ -199,13 +230,17 @@ class EngineManager(manager.Manager): arg2 -> Name of the stack you want to delete. arg3 -> Params passed from API. """ + + self._authenticate(context) + st = db_api.stack_get(None, stack_name) if not st: return {'Error': 'No stack by that name'} logger.info('deleting stack %s' % stack_name) - ps = parser.Stack(st.name, st.raw_template.parsed_template.template, + ps = parser.Stack(context, st.name, + st.raw_template.parsed_template.template, st.id, params) ps.delete() return None @@ -224,12 +259,16 @@ class EngineManager(manager.Manager): 'ResourceProperties': event.resource_properties, 'ResourceStatus': event.name} - def list_events(self, context, stack_name): + def list_events(self, context, stack_name, params): """ The list_events method lists all events associated with a given stack. arg1 -> RPC context. arg2 -> Name of the stack you want to get events for. + arg3 -> Params passed from API. """ + + self._authenticate(context) + if stack_name is not None: st = db_api.stack_get(None, stack_name) if not st: @@ -242,6 +281,9 @@ class EngineManager(manager.Manager): return {'events': [self.parse_event(e) for e in events]} def event_create(self, context, event): + + self._authenticate(context) + stack_name = event['stack'] resource_name = event['resource'] stack = db_api.stack_get(None, stack_name) @@ -274,6 +316,8 @@ class EngineManager(manager.Manager): """ Return the names of the stacks registered with Heat. """ + self._authenticate(context) + stacks = db_api.stack_get_all(None) return [s.name for s in stacks] @@ -281,6 +325,8 @@ class EngineManager(manager.Manager): """ Return the resource IDs of the given stack. """ + self._authenticate(context) + stack = db_api.stack_get(None, stack_name) if stack: return [r.name for r in stack.resources] @@ -291,6 +337,8 @@ class EngineManager(manager.Manager): """ Get the metadata for the given resource. """ + self._authenticate(context) + s = db_api.stack_get(None, stack_name) if not s: return ['stack', None] @@ -306,6 +354,8 @@ class EngineManager(manager.Manager): """ Update the metadata for the given resource. """ + self._authenticate(context) + s = db_api.stack_get(None, stack_name) if not s: return ['stack', None] diff --git a/heat/engine/parser.py b/heat/engine/parser.py index 45d916c412..39dcf605b6 100644 --- a/heat/engine/parser.py +++ b/heat/engine/parser.py @@ -58,9 +58,10 @@ class Stack(object): DELETE_FAILED = 'DELETE_FAILED' DELETE_COMPLETE = 'DELETE_COMPLETE' - def __init__(self, stack_name, template, stack_id=0, parms=None, + def __init__(self, context, stack_name, template, stack_id=0, parms=None, metadata_server=None): self.id = stack_id + self.context = context self.t = template self.maps = self.t.get('Mappings', {}) self.outputs = self.t.get('Outputs', {}) @@ -92,11 +93,6 @@ class Stack(object): if parms != None: self._apply_user_parameters(parms) - if isinstance(parms['KeyStoneCreds'], (basestring, unicode)): - self.creds = eval(parms['KeyStoneCreds']) - else: - self.creds = parms['KeyStoneCreds'] - self.resources = {} for rname, res in self.t['Resources'].items(): ResourceClass = RESOURCE_CLASSES.get(res['Type'], diff --git a/heat/engine/resources.py b/heat/engine/resources.py index 02a0adce6e..b90b55f820 100644 --- a/heat/engine/resources.py +++ b/heat/engine/resources.py @@ -74,17 +74,18 @@ class Resource(object): if service_type in self._nova: return self._nova[service_type] - username = self.stack.creds['username'] - password = self.stack.creds['password'] - tenant = self.stack.creds['tenant'] - auth_url = self.stack.creds['auth_url'] if service_type == 'compute': service_name = 'nova' else: service_name = None - self._nova[service_type] = client.Client(username, password, tenant, - auth_url, + con = self.stack.context + self._nova[service_type] = client.Client(con.username, + con.password, + con.tenant, + con.auth_url, + proxy_token=con.auth_token, + proxy_tenant_id=con.tenant_id, service_type=service_type, service_name=service_name) return self._nova[service_type] diff --git a/heat/metadata/api/v1/metadata.py b/heat/metadata/api/v1/metadata.py index 40b406b36f..4620a1645d 100644 --- a/heat/metadata/api/v1/metadata.py +++ b/heat/metadata/api/v1/metadata.py @@ -18,7 +18,7 @@ import json from webob.exc import Response from heat.common import wsgi -from heat import context +from heat.common import context from heat import rpc diff --git a/heat/rpc/amqp.py b/heat/rpc/amqp.py index 8585b591ff..8f02f9471e 100644 --- a/heat/rpc/amqp.py +++ b/heat/rpc/amqp.py @@ -34,7 +34,7 @@ import uuid from eventlet import greenpool from eventlet import pools -from heat import context +from heat.common import context from heat.common import exception from heat.common import config from heat.openstack.common import local diff --git a/heat/rpc/impl_fake.py b/heat/rpc/impl_fake.py index a6c2aba448..995f47e111 100644 --- a/heat/rpc/impl_fake.py +++ b/heat/rpc/impl_fake.py @@ -26,7 +26,7 @@ import traceback import eventlet -from heat import context +from heat.common import context from heat.common import config from heat.rpc import common as rpc_common diff --git a/heat/service.py b/heat/service.py index 359142730e..9a118ff67c 100644 --- a/heat/service.py +++ b/heat/service.py @@ -31,8 +31,8 @@ from heat.openstack.common import importutils from heat.common import utils as heat_utils from heat.common import exception +from heat.common import context -from heat import context from heat import rpc from heat import version diff --git a/heat/tests/test_resources.py b/heat/tests/test_resources.py index 52389a4176..927c19acf5 100644 --- a/heat/tests/test_resources.py +++ b/heat/tests/test_resources.py @@ -38,7 +38,7 @@ class instancesTest(unittest.TestCase): parameters = {} params['KeyStoneCreds'] = None t['Parameters']['KeyName']['Value'] = 'test' - stack = parser.Stack('test_stack', t, 0, params) + stack = parser.Stack(None, 'test_stack', t, 0, params) self.m.StubOutWithMock(db_api, 'resource_get_by_name_and_stack') db_api.resource_get_by_name_and_stack(None, 'test_resource_name',\ @@ -88,7 +88,7 @@ class instancesTest(unittest.TestCase): parameters = {} params['KeyStoneCreds'] = None t['Parameters']['KeyName']['Value'] = 'test' - stack = parser.Stack('test_stack', t, 0, params) + stack = parser.Stack(None, 'test_stack', t, 0, params) self.m.StubOutWithMock(db_api, 'resource_get_by_name_and_stack') db_api.resource_get_by_name_and_stack(None, 'test_resource_name',\ diff --git a/heat/tests/test_stacks.py b/heat/tests/test_stacks.py index 8f31f82410..72e3b2fce7 100644 --- a/heat/tests/test_stacks.py +++ b/heat/tests/test_stacks.py @@ -36,9 +36,8 @@ class stacksTest(unittest.TestCase): f.close() params = {} parameters = {} - params['KeyStoneCreds'] = None t['Parameters']['KeyName']['Value'] = 'test' - stack = parser.Stack(stack_name, t, 0, params) + stack = parser.Stack(None, stack_name, t, 0, params) self.m.StubOutWithMock(instances.Instance, 'nova') instances.Instance.nova().AndReturn(self.fc) instances.Instance.nova().AndReturn(self.fc) @@ -130,6 +129,8 @@ class stacksTest(unittest.TestCase): assert(result['ResourceProperties']['InstanceType'] == 'm1.large') def test_stack_list(self): + self.m.StubOutWithMock(manager.EngineManager, '_authenticate') + manager.EngineManager._authenticate(None).AndReturn(True) stack = self.start_wordpress_stack('test_stack_list') rt = {} rt['template'] = stack.t @@ -152,9 +153,8 @@ class stacksTest(unittest.TestCase): t = json.loads(f.read()) params = {} parameters = {} - params['KeyStoneCreds'] = None t['Parameters']['KeyName']['Value'] = 'test' - stack = parser.Stack('test_stack_list', t, 0, params) + stack = parser.Stack(None, 'test_stack_list', t, 0, params) man = manager.EngineManager() sl = man.list_stacks(None, params) diff --git a/heat/tests/test_validate.py b/heat/tests/test_validate.py index 4d90e4437d..9c07211233 100644 --- a/heat/tests/test_validate.py +++ b/heat/tests/test_validate.py @@ -212,9 +212,10 @@ class validateTest(unittest.TestCase): def test_validate_volumeattach_valid(self): t = json.loads(test_template_volumeattach % 'vdq') + self.m.StubOutWithMock(managers.EngineManager, '_authenticate') + managers.EngineManager._authenticate(None).AndReturn(True) params = {} - params['KeyStoneCreds'] = None - stack = parser.Stack('test_stack', t, 0, params) + stack = parser.Stack(None, 'test_stack', t, 0, params) self.m.StubOutWithMock(db_api, 'resource_get_by_name_and_stack') db_api.resource_get_by_name_and_stack(None, 'test_resource_name',\ @@ -229,9 +230,10 @@ class validateTest(unittest.TestCase): def test_validate_volumeattach_invalid(self): t = json.loads(test_template_volumeattach % 'sda') + self.m.StubOutWithMock(managers.EngineManager, '_authenticate') + managers.EngineManager._authenticate(None).AndReturn(True) params = {} - params['KeyStoneCreds'] = None - stack = parser.Stack('test_stack', t, 0, params) + stack = parser.Stack(None, 'test_stack', t, 0, params) self.m.StubOutWithMock(db_api, 'resource_get_by_name_and_stack') db_api.resource_get_by_name_and_stack(None, 'test_resource_name',\ @@ -248,7 +250,8 @@ class validateTest(unittest.TestCase): t = json.loads(test_template_ref % 'WikiDatabase') t['Parameters']['KeyName']['Value'] = 'test' params = {} - params['KeyStoneCreds'] = None + self.m.StubOutWithMock(managers.EngineManager, '_authenticate') + managers.EngineManager._authenticate(None).AndReturn(True) self.m.StubOutWithMock(instances.Instance, 'nova') instances.Instance.nova().AndReturn(self.fc) @@ -264,7 +267,8 @@ class validateTest(unittest.TestCase): t = json.loads(test_template_ref % 'WikiDatabasez') t['Parameters']['KeyName']['Value'] = 'test' params = {} - params['KeyStoneCreds'] = None + self.m.StubOutWithMock(managers.EngineManager, '_authenticate') + managers.EngineManager._authenticate(None).AndReturn(True) self.m.StubOutWithMock(instances.Instance, 'nova') instances.Instance.nova().AndReturn(self.fc) @@ -279,7 +283,8 @@ class validateTest(unittest.TestCase): t = json.loads(test_template_findinmap_valid) t['Parameters']['KeyName']['Value'] = 'test' params = {} - params['KeyStoneCreds'] = None + self.m.StubOutWithMock(managers.EngineManager, '_authenticate') + managers.EngineManager._authenticate(None).AndReturn(True) self.m.StubOutWithMock(instances.Instance, 'nova') instances.Instance.nova().AndReturn(self.fc) @@ -294,7 +299,8 @@ class validateTest(unittest.TestCase): t = json.loads(test_template_findinmap_invalid) t['Parameters']['KeyName']['Value'] = 'test' params = {} - params['KeyStoneCreds'] = None + self.m.StubOutWithMock(managers.EngineManager, '_authenticate') + managers.EngineManager._authenticate(None).AndReturn(True) self.m.StubOutWithMock(instances.Instance, 'nova') instances.Instance.nova().AndReturn(self.fc)