From 049d9bcbe4121725aab593f9ba6dd9648e75d153 Mon Sep 17 00:00:00 2001 From: Colleen Murphy Date: Sun, 20 Jan 2019 19:57:46 +0100 Subject: [PATCH] Add access rules to token validation This change adds application credential access rules to the token model and ensures that only clients (that is, keystonemiddleware) that support access rule enforcement are allowed to validate tokens containing access rules. Depends-on: https://review.openstack.org/633369 bp whitelist-extension-for-app-creds Change-Id: I301651369cf03e06550bc29eb534506674e56a1f --- keystone/api/auth.py | 5 +- keystone/common/authorization.py | 4 ++ keystone/common/rbac_enforcer/enforcer.py | 5 +- keystone/common/render_token.py | 4 ++ keystone/models/token_model.py | 3 ++ .../middleware/auth_context.py | 13 +++-- keystone/tests/unit/test_v3_auth.py | 52 ++++++++++++++++++- keystone/token/provider.py | 23 +++++++- lower-constraints.txt | 2 +- requirements.txt | 2 +- 10 files changed, 104 insertions(+), 9 deletions(-) diff --git a/keystone/api/auth.py b/keystone/api/auth.py index 96c17f1301..d399df433c 100644 --- a/keystone/api/auth.py +++ b/keystone/api/auth.py @@ -286,12 +286,15 @@ class AuthTokenResource(_AuthFederationWebSSOBase): token_id = flask.request.headers.get( authorization.SUBJECT_TOKEN_HEADER) + access_rules_support = flask.request.headers.get( + authorization.ACCESS_RULES_HEADER) allow_expired = strutils.bool_from_string( flask.request.args.get('allow_expired')) window_secs = CONF.token.allow_expired_window if allow_expired else 0 include_catalog = 'nocatalog' not in flask.request.args token = PROVIDERS.token_provider_api.validate_token( - token_id, window_seconds=window_secs) + token_id, window_seconds=window_secs, + access_rules_support=access_rules_support) token_resp = render_token.render_token_response_from_model( token, include_catalog=include_catalog) resp_body = jsonutils.dumps(token_resp) diff --git a/keystone/common/authorization.py b/keystone/common/authorization.py index a15c9eaac3..b236367b8b 100644 --- a/keystone/common/authorization.py +++ b/keystone/common/authorization.py @@ -32,3 +32,7 @@ SUBJECT_TOKEN_HEADER = 'X-Subject-Token' # nosec # Environment variable used to convey the Keystone auth context, # the user credential used for policy enforcement. AUTH_CONTEXT_ENV = 'KEYSTONE_AUTH_CONTEXT' + +# Header set by versions of keystonemiddleware that understand application +# credential access rules +ACCESS_RULES_HEADER = 'OpenStack-Identity-Access-Rules' diff --git a/keystone/common/rbac_enforcer/enforcer.py b/keystone/common/rbac_enforcer/enforcer.py index defae6c754..cb5034b068 100644 --- a/keystone/common/rbac_enforcer/enforcer.py +++ b/keystone/common/rbac_enforcer/enforcer.py @@ -193,6 +193,8 @@ class RBACEnforcer(object): # of the auth paths. target = 'token' subject_token = flask.request.headers.get('X-Subject-Token') + access_rules_support = flask.request.headers.get( + authorization.ACCESS_RULES_HEADER) if subject_token is not None: allow_expired = (strutils.bool_from_string( flask.request.args.get('allow_expired', False), @@ -201,7 +203,8 @@ class RBACEnforcer(object): window_seconds = CONF.token.allow_expired_window token = PROVIDER_APIS.token_provider_api.validate_token( subject_token, - window_seconds=window_seconds + window_seconds=window_seconds, + access_rules_support=access_rules_support ) # TODO(morgan): Expand extracted data from the subject token. ret_dict[target] = {} diff --git a/keystone/common/render_token.py b/keystone/common/render_token.py index 9363639114..320260b1f5 100644 --- a/keystone/common/render_token.py +++ b/keystone/common/render_token.py @@ -138,5 +138,9 @@ def render_token_response_from_model(token, include_catalog=True): ) restricted = not token.application_credential['unrestricted'] token_reference['token'][key]['restricted'] = restricted + if token.application_credential.get('access_rules'): + token_reference['token'][key]['access_rules'] = ( + token.application_credential['access_rules'] + ) return token_reference diff --git a/keystone/models/token_model.py b/keystone/models/token_model.py index 7f190286a7..077f138b22 100644 --- a/keystone/models/token_model.py +++ b/keystone/models/token_model.py @@ -29,6 +29,9 @@ PROVIDERS = provider_api.ProviderAPIs V3 = 'v3.0' VERSIONS = frozenset([V3]) +# minimum access rules support +ACCESS_RULES_MIN_VERSION = 1.0 + class TokenModel(object): """An object that represents a token emitted by keystone. diff --git a/keystone/server/flask/request_processing/middleware/auth_context.py b/keystone/server/flask/request_processing/middleware/auth_context.py index 56f1df397c..ae6947a459 100644 --- a/keystone/server/flask/request_processing/middleware/auth_context.py +++ b/keystone/server/flask/request_processing/middleware/auth_context.py @@ -55,6 +55,9 @@ LOG = log.getLogger(__name__) JSON_ENCODE_CONTENT_TYPES = set(['application/json', 'application/json-home']) +# minimum access rules support +ACCESS_RULES_MIN_VERSION = token_model.ACCESS_RULES_MIN_VERSION + def best_match_language(req): """Determine the best available locale. @@ -236,12 +239,14 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin, kwargs_to_fetch_token = True def __init__(self, app): - super(AuthContextMiddleware, self).__init__(app, log=LOG) + super(AuthContextMiddleware, self).__init__(app, log=LOG, + service_type='identity') self.token = None def fetch_token(self, token, **kwargs): try: - self.token = self.token_provider_api.validate_token(token) + self.token = self.token_provider_api.validate_token( + token, access_rules_support=ACCESS_RULES_MIN_VERSION) return render_token.render_token_response_from_model(self.token) except exception.TokenNotFound: raise auth_token.InvalidToken(_('Could not find token')) @@ -419,7 +424,9 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin, # do not, and should not, use. This adds them in to the context. if not self.token: self.token = PROVIDERS.token_provider_api.validate_token( - request.user_token + request.user_token, + access_rules_support=request.headers.get( + authorization.ACCESS_RULES_HEADER) ) self._keystone_specific_values(self.token, request_context) request_context.auth_token = request.user_token diff --git a/keystone/tests/unit/test_v3_auth.py b/keystone/tests/unit/test_v3_auth.py index 8ac266c1ba..926c102862 100644 --- a/keystone/tests/unit/test_v3_auth.py +++ b/keystone/tests/unit/test_v3_auth.py @@ -5583,7 +5583,7 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase): self.auth_plugin_config_override( methods=['application_credential', 'password', 'token']) - def _make_app_cred(self, expires=None): + def _make_app_cred(self, expires=None, access_rules=None): roles = [{'id': self.role_id}] data = { 'id': uuid.uuid4().hex, @@ -5596,8 +5596,19 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase): } if expires: data['expires_at'] = expires + if access_rules: + data['access_rules'] = access_rules return data + def _validate_token(self, token, headers=None, expected_status=http_client.OK): + path = '/v3/auth/tokens' + headers = headers or {} + headers.update({'X-Auth-Token': token, 'X-Subject-Token': token}) + with self.test_client() as c: + resp = c.get(path, headers=headers, + expected_status_code=expected_status) + return resp + def test_valid_application_credential_succeeds(self): app_cred = self._make_app_cred() app_cred_ref = self.app_cred_api.create_application_credential( @@ -5780,3 +5791,42 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase): project_id=new_project['id']) self.v3_create_token(app_cred_auth, expected_status=http_client.UNAUTHORIZED) + + def test_application_credential_with_access_rules(self): + access_rules = [ + { + 'id': uuid.uuid4().hex, + 'path': '/v2.1/servers', + 'method': 'POST', + 'service': uuid.uuid4().hex, + } + ] + app_cred = self._make_app_cred(access_rules=access_rules) + app_cred_ref = self.app_cred_api.create_application_credential( + app_cred) + auth_data = self.build_authentication_request( + app_cred_id=app_cred_ref['id'], secret=app_cred_ref['secret']) + resp = self.v3_create_token(auth_data, + expected_status=http_client.CREATED) + token = resp.headers.get('X-Subject-Token') + headers = {'OpenStack-Identity-Access-Rules': '1.0'} + self._validate_token(token, headers=headers) + + def test_application_credential_access_rules_without_header_fails(self): + access_rules = [ + { + 'id': uuid.uuid4().hex, + 'path': '/v2.1/servers', + 'method': 'POST', + 'service': uuid.uuid4().hex, + } + ] + app_cred = self._make_app_cred(access_rules=access_rules) + app_cred_ref = self.app_cred_api.create_application_credential( + app_cred) + auth_data = self.build_authentication_request( + app_cred_id=app_cred_ref['id'], secret=app_cred_ref['secret']) + resp = self.v3_create_token(auth_data, + expected_status=http_client.CREATED) + token = resp.headers.get('X-Subject-Token') + self._validate_token(token, expected_status=http_client.NOT_FOUND) diff --git a/keystone/token/provider.py b/keystone/token/provider.py index c455885da1..e3c45a3033 100644 --- a/keystone/token/provider.py +++ b/keystone/token/provider.py @@ -51,6 +51,9 @@ UnsupportedTokenVersionException = exception.UnsupportedTokenVersionException V3 = token_model.V3 VERSIONS = token_model.VERSIONS +# minimum access rules support +ACCESS_RULES_MIN_VERSION = token_model.ACCESS_RULES_MIN_VERSION + def default_expire_time(): """Determine when a fresh token should expire. @@ -135,13 +138,15 @@ class Manager(manager.Manager): def check_revocation(self, token): return self.check_revocation_v3(token) - def validate_token(self, token_id, window_seconds=0): + def validate_token(self, token_id, window_seconds=0, + access_rules_support=None): if not token_id: raise exception.TokenNotFound(_('No token in the request')) try: token = self._validate_token(token_id) self._is_valid_token(token, window_seconds=window_seconds) + self._validate_token_access_rules(token, access_rules_support) return token except exception.Unauthorized as e: LOG.debug('Unable to validate token: %s', e) @@ -199,6 +204,22 @@ class Manager(manager.Manager): else: raise exception.TokenNotFound(_('Failed to validate token')) + def _validate_token_access_rules(self, token, access_rules_support=None): + if token.application_credential_id: + app_cred_api = PROVIDERS.application_credential_api + app_cred = app_cred_api.get_application_credential( + token.application_credential_id) + if (app_cred.get('access_rules') is not None and + (not access_rules_support or + (float(access_rules_support) < ACCESS_RULES_MIN_VERSION))): + LOG.exception('Attempted to use application credential' + ' access rules with a middleware that does not' + ' understand them. You must upgrade' + ' keystonemiddleware on all services that' + ' accept application credentials as an' + ' authentication method.') + raise exception.TokenNotFound(_('Failed to validate token')) + def issue_token(self, user_id, method_names, expires_at=None, system=None, project_id=None, domain_id=None, auth_context=None, trust_id=None, app_cred_id=None, diff --git a/lower-constraints.txt b/lower-constraints.txt index 8c78350ef5..2e061fea6d 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -16,7 +16,7 @@ hacking==1.1.0 iso8601==0.1.12 jsonschema==2.6.0 keystoneauth1==3.4.0 -keystonemiddleware==5.1.0 +keystonemiddleware==7.0.0 ldappool===2.3.1 lxml==3.4.1 mock==2.0.0 diff --git a/requirements.txt b/requirements.txt index d33a948748..36a0cdc68c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ sqlalchemy-migrate>=0.11.0 # Apache-2.0 stevedore>=1.20.0 # Apache-2.0 passlib>=1.7.0 # BSD python-keystoneclient>=3.8.0 # Apache-2.0 -keystonemiddleware>=5.1.0 # Apache-2.0 +keystonemiddleware>=7.0.0 # Apache-2.0 bcrypt>=3.1.3 # Apache-2.0 scrypt>=0.8.0 # BSD oslo.cache>=1.26.0 # Apache-2.0