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
This commit is contained in:
Colleen Murphy 2019-01-20 19:57:46 +01:00
parent 67682dcd07
commit 049d9bcbe4
10 changed files with 104 additions and 9 deletions

View File

@ -286,12 +286,15 @@ class AuthTokenResource(_AuthFederationWebSSOBase):
token_id = flask.request.headers.get( token_id = flask.request.headers.get(
authorization.SUBJECT_TOKEN_HEADER) authorization.SUBJECT_TOKEN_HEADER)
access_rules_support = flask.request.headers.get(
authorization.ACCESS_RULES_HEADER)
allow_expired = strutils.bool_from_string( allow_expired = strutils.bool_from_string(
flask.request.args.get('allow_expired')) flask.request.args.get('allow_expired'))
window_secs = CONF.token.allow_expired_window if allow_expired else 0 window_secs = CONF.token.allow_expired_window if allow_expired else 0
include_catalog = 'nocatalog' not in flask.request.args include_catalog = 'nocatalog' not in flask.request.args
token = PROVIDERS.token_provider_api.validate_token( 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_resp = render_token.render_token_response_from_model(
token, include_catalog=include_catalog) token, include_catalog=include_catalog)
resp_body = jsonutils.dumps(token_resp) resp_body = jsonutils.dumps(token_resp)

View File

@ -32,3 +32,7 @@ SUBJECT_TOKEN_HEADER = 'X-Subject-Token' # nosec
# Environment variable used to convey the Keystone auth context, # Environment variable used to convey the Keystone auth context,
# the user credential used for policy enforcement. # the user credential used for policy enforcement.
AUTH_CONTEXT_ENV = 'KEYSTONE_AUTH_CONTEXT' 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'

View File

@ -193,6 +193,8 @@ class RBACEnforcer(object):
# of the auth paths. # of the auth paths.
target = 'token' target = 'token'
subject_token = flask.request.headers.get('X-Subject-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: if subject_token is not None:
allow_expired = (strutils.bool_from_string( allow_expired = (strutils.bool_from_string(
flask.request.args.get('allow_expired', False), flask.request.args.get('allow_expired', False),
@ -201,7 +203,8 @@ class RBACEnforcer(object):
window_seconds = CONF.token.allow_expired_window window_seconds = CONF.token.allow_expired_window
token = PROVIDER_APIS.token_provider_api.validate_token( token = PROVIDER_APIS.token_provider_api.validate_token(
subject_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. # TODO(morgan): Expand extracted data from the subject token.
ret_dict[target] = {} ret_dict[target] = {}

View File

@ -138,5 +138,9 @@ def render_token_response_from_model(token, include_catalog=True):
) )
restricted = not token.application_credential['unrestricted'] restricted = not token.application_credential['unrestricted']
token_reference['token'][key]['restricted'] = restricted 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 return token_reference

View File

@ -29,6 +29,9 @@ PROVIDERS = provider_api.ProviderAPIs
V3 = 'v3.0' V3 = 'v3.0'
VERSIONS = frozenset([V3]) VERSIONS = frozenset([V3])
# minimum access rules support
ACCESS_RULES_MIN_VERSION = 1.0
class TokenModel(object): class TokenModel(object):
"""An object that represents a token emitted by keystone. """An object that represents a token emitted by keystone.

View File

@ -55,6 +55,9 @@ LOG = log.getLogger(__name__)
JSON_ENCODE_CONTENT_TYPES = set(['application/json', JSON_ENCODE_CONTENT_TYPES = set(['application/json',
'application/json-home']) 'application/json-home'])
# minimum access rules support
ACCESS_RULES_MIN_VERSION = token_model.ACCESS_RULES_MIN_VERSION
def best_match_language(req): def best_match_language(req):
"""Determine the best available locale. """Determine the best available locale.
@ -236,12 +239,14 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin,
kwargs_to_fetch_token = True kwargs_to_fetch_token = True
def __init__(self, app): def __init__(self, app):
super(AuthContextMiddleware, self).__init__(app, log=LOG) super(AuthContextMiddleware, self).__init__(app, log=LOG,
service_type='identity')
self.token = None self.token = None
def fetch_token(self, token, **kwargs): def fetch_token(self, token, **kwargs):
try: 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) return render_token.render_token_response_from_model(self.token)
except exception.TokenNotFound: except exception.TokenNotFound:
raise auth_token.InvalidToken(_('Could not find token')) 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. # do not, and should not, use. This adds them in to the context.
if not self.token: if not self.token:
self.token = PROVIDERS.token_provider_api.validate_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) self._keystone_specific_values(self.token, request_context)
request_context.auth_token = request.user_token request_context.auth_token = request.user_token

View File

@ -5583,7 +5583,7 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase):
self.auth_plugin_config_override( self.auth_plugin_config_override(
methods=['application_credential', 'password', 'token']) 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}] roles = [{'id': self.role_id}]
data = { data = {
'id': uuid.uuid4().hex, 'id': uuid.uuid4().hex,
@ -5596,8 +5596,19 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase):
} }
if expires: if expires:
data['expires_at'] = expires data['expires_at'] = expires
if access_rules:
data['access_rules'] = access_rules
return data 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): def test_valid_application_credential_succeeds(self):
app_cred = self._make_app_cred() app_cred = self._make_app_cred()
app_cred_ref = self.app_cred_api.create_application_credential( app_cred_ref = self.app_cred_api.create_application_credential(
@ -5780,3 +5791,42 @@ class ApplicationCredentialAuth(test_v3.RestfulTestCase):
project_id=new_project['id']) project_id=new_project['id'])
self.v3_create_token(app_cred_auth, self.v3_create_token(app_cred_auth,
expected_status=http_client.UNAUTHORIZED) 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)

View File

@ -51,6 +51,9 @@ UnsupportedTokenVersionException = exception.UnsupportedTokenVersionException
V3 = token_model.V3 V3 = token_model.V3
VERSIONS = token_model.VERSIONS VERSIONS = token_model.VERSIONS
# minimum access rules support
ACCESS_RULES_MIN_VERSION = token_model.ACCESS_RULES_MIN_VERSION
def default_expire_time(): def default_expire_time():
"""Determine when a fresh token should expire. """Determine when a fresh token should expire.
@ -135,13 +138,15 @@ class Manager(manager.Manager):
def check_revocation(self, token): def check_revocation(self, token):
return self.check_revocation_v3(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: if not token_id:
raise exception.TokenNotFound(_('No token in the request')) raise exception.TokenNotFound(_('No token in the request'))
try: try:
token = self._validate_token(token_id) token = self._validate_token(token_id)
self._is_valid_token(token, window_seconds=window_seconds) self._is_valid_token(token, window_seconds=window_seconds)
self._validate_token_access_rules(token, access_rules_support)
return token return token
except exception.Unauthorized as e: except exception.Unauthorized as e:
LOG.debug('Unable to validate token: %s', e) LOG.debug('Unable to validate token: %s', e)
@ -199,6 +204,22 @@ class Manager(manager.Manager):
else: else:
raise exception.TokenNotFound(_('Failed to validate token')) 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, def issue_token(self, user_id, method_names, expires_at=None,
system=None, project_id=None, domain_id=None, system=None, project_id=None, domain_id=None,
auth_context=None, trust_id=None, app_cred_id=None, auth_context=None, trust_id=None, app_cred_id=None,

View File

@ -16,7 +16,7 @@ hacking==1.1.0
iso8601==0.1.12 iso8601==0.1.12
jsonschema==2.6.0 jsonschema==2.6.0
keystoneauth1==3.4.0 keystoneauth1==3.4.0
keystonemiddleware==5.1.0 keystonemiddleware==7.0.0
ldappool===2.3.1 ldappool===2.3.1
lxml==3.4.1 lxml==3.4.1
mock==2.0.0 mock==2.0.0

View File

@ -17,7 +17,7 @@ sqlalchemy-migrate>=0.11.0 # Apache-2.0
stevedore>=1.20.0 # Apache-2.0 stevedore>=1.20.0 # Apache-2.0
passlib>=1.7.0 # BSD passlib>=1.7.0 # BSD
python-keystoneclient>=3.8.0 # Apache-2.0 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 bcrypt>=3.1.3 # Apache-2.0
scrypt>=0.8.0 # BSD scrypt>=0.8.0 # BSD
oslo.cache>=1.26.0 # Apache-2.0 oslo.cache>=1.26.0 # Apache-2.0