Convert /v3/users to flask native dispatching
Convert /v3/users to use flask native dispatching. The following test changes were required: * Application Credentials did not have the plural form in the JSON Home document. The JSON Home document was corrected both in code and in tests. * Application Credentials "patch" test needed to be refactored to look for METHOD_NOT_ALLOWED instead of NOT FOUND for invalid/unimplemented methods. The "assertValidErrorResponse" method was insufficient and the test now uses the flask test_client mechanism instead. Change-Id: Iedaf405d11450b11e2d1fcdfae45ccb8eeb6f255 Partial-Bug: #1776504
This commit is contained in:
parent
f872a40290
commit
86f968163e
|
@ -32,6 +32,7 @@ from keystone.api import roles
|
||||||
from keystone.api import services
|
from keystone.api import services
|
||||||
from keystone.api import system
|
from keystone.api import system
|
||||||
from keystone.api import trusts
|
from keystone.api import trusts
|
||||||
|
from keystone.api import users
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'auth',
|
'auth',
|
||||||
|
@ -56,6 +57,7 @@ __all__ = (
|
||||||
'services',
|
'services',
|
||||||
'system',
|
'system',
|
||||||
'trusts',
|
'trusts',
|
||||||
|
'users',
|
||||||
)
|
)
|
||||||
|
|
||||||
__apis__ = (
|
__apis__ = (
|
||||||
|
@ -81,4 +83,5 @@ __apis__ = (
|
||||||
services,
|
services,
|
||||||
system,
|
system,
|
||||||
trusts,
|
trusts,
|
||||||
|
users,
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,6 +19,11 @@ import functools
|
||||||
|
|
||||||
from keystone.common import json_home
|
from keystone.common import json_home
|
||||||
|
|
||||||
|
# OS-EC2 "extension"
|
||||||
|
os_ec2_resource_rel_func = functools.partial(
|
||||||
|
json_home.build_v3_extension_resource_relation,
|
||||||
|
extension_name='OS-EC2', extension_version='1.0')
|
||||||
|
|
||||||
# OS-EP-FILTER "extension"
|
# OS-EP-FILTER "extension"
|
||||||
os_ep_filter_resource_rel_func = functools.partial(
|
os_ep_filter_resource_rel_func = functools.partial(
|
||||||
json_home.build_v3_extension_resource_relation,
|
json_home.build_v3_extension_resource_relation,
|
||||||
|
|
|
@ -0,0 +1,723 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# This file handles all flask-restful resources for /v3/users
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import flask
|
||||||
|
from oslo_log import log
|
||||||
|
from oslo_serialization import jsonutils
|
||||||
|
from six.moves import http_client
|
||||||
|
from werkzeug import exceptions
|
||||||
|
|
||||||
|
from keystone.api._shared import json_home_relations
|
||||||
|
from keystone.application_credential import schema as app_cred_schema
|
||||||
|
from keystone.common import json_home
|
||||||
|
from keystone.common import provider_api
|
||||||
|
from keystone.common import rbac_enforcer
|
||||||
|
from keystone.common import utils
|
||||||
|
from keystone.common import validation
|
||||||
|
import keystone.conf
|
||||||
|
from keystone import exception as ks_exception
|
||||||
|
from keystone.i18n import _
|
||||||
|
from keystone.identity import schema
|
||||||
|
from keystone import notifications
|
||||||
|
from keystone.server import flask as ks_flask
|
||||||
|
|
||||||
|
|
||||||
|
CRED_TYPE_EC2 = 'ec2'
|
||||||
|
CONF = keystone.conf.CONF
|
||||||
|
ENFORCER = rbac_enforcer.RBACEnforcer
|
||||||
|
LOG = log.getLogger(__name__)
|
||||||
|
PROVIDERS = provider_api.ProviderAPIs
|
||||||
|
|
||||||
|
ACCESS_TOKEN_ID_PARAMETER_RELATION = (
|
||||||
|
json_home_relations.os_oauth1_parameter_rel_func(
|
||||||
|
parameter_name='access_token_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_v3_to_ec2_credential(credential):
|
||||||
|
# Prior to bug #1259584 fix, blob was stored unserialized
|
||||||
|
# but it should be stored as a json string for compatibility
|
||||||
|
# with the v3 credentials API. Fall back to the old behavior
|
||||||
|
# for backwards compatibility with existing DB contents
|
||||||
|
try:
|
||||||
|
blob = jsonutils.loads(credential['blob'])
|
||||||
|
except TypeError:
|
||||||
|
blob = credential['blob']
|
||||||
|
return {'user_id': credential.get('user_id'),
|
||||||
|
'tenant_id': credential.get('project_id'),
|
||||||
|
'access': blob.get('access'),
|
||||||
|
'secret': blob.get('secret'),
|
||||||
|
'trust_id': blob.get('trust_id')}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_token_entity(entity):
|
||||||
|
|
||||||
|
formatted_entity = entity.copy()
|
||||||
|
access_token_id = formatted_entity['id']
|
||||||
|
user_id = formatted_entity.get('authorizing_user_id', '')
|
||||||
|
if 'role_ids' in entity:
|
||||||
|
formatted_entity.pop('role_ids')
|
||||||
|
if 'access_secret' in entity:
|
||||||
|
formatted_entity.pop('access_secret')
|
||||||
|
|
||||||
|
url = ('/users/%(user_id)s/OS-OAUTH1/access_tokens/%(access_token_id)s'
|
||||||
|
'/roles' % {'user_id': user_id,
|
||||||
|
'access_token_id': access_token_id})
|
||||||
|
|
||||||
|
formatted_entity.setdefault('links', {})
|
||||||
|
formatted_entity['links']['roles'] = (ks_flask.base_url(url))
|
||||||
|
|
||||||
|
return formatted_entity
|
||||||
|
|
||||||
|
|
||||||
|
def _check_unrestricted_application_credential(token):
|
||||||
|
if 'application_credential' in token.methods:
|
||||||
|
if not token.application_credential['unrestricted']:
|
||||||
|
action = _("Using method 'application_credential' is not "
|
||||||
|
"allowed for managing additional application "
|
||||||
|
"credentials.")
|
||||||
|
raise ks_exception.ForbiddenAction(action=action)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_enforcer_target_data_owner_and_user_id_match():
|
||||||
|
ref = {}
|
||||||
|
if flask.request.view_args:
|
||||||
|
credential_id = flask.request.view_args.get('credential_id')
|
||||||
|
if credential_id is not None:
|
||||||
|
hashed_id = utils.hash_access_key(credential_id)
|
||||||
|
ref['credential'] = PROVIDERS.credential_api.get_credential(
|
||||||
|
hashed_id)
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
def _format_role_entity(role_id):
|
||||||
|
role = PROVIDERS.role_api.get_role(role_id)
|
||||||
|
formatted_entity = role.copy()
|
||||||
|
if 'description' in role:
|
||||||
|
formatted_entity.pop('description')
|
||||||
|
if 'enabled' in role:
|
||||||
|
formatted_entity.pop('enabled')
|
||||||
|
return formatted_entity
|
||||||
|
|
||||||
|
|
||||||
|
class UserResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'users'
|
||||||
|
member_key = 'user'
|
||||||
|
get_member_from_driver = PROVIDERS.deferred_provider_lookup(
|
||||||
|
api='identity_api', method='get_user')
|
||||||
|
|
||||||
|
def get(self, user_id=None):
|
||||||
|
"""Get a user resource or list users.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users
|
||||||
|
GET/HEAD /v3/users/{user_id}
|
||||||
|
"""
|
||||||
|
if user_id is not None:
|
||||||
|
return self._get_user(user_id)
|
||||||
|
return self._list_users()
|
||||||
|
|
||||||
|
def _get_user(self, user_id):
|
||||||
|
"""Get a user resource.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:get_user')
|
||||||
|
ref = PROVIDERS.identity_api.get_user(user_id)
|
||||||
|
return self.wrap_member(ref)
|
||||||
|
|
||||||
|
def _list_users(self):
|
||||||
|
"""List users.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users
|
||||||
|
"""
|
||||||
|
filters = ('domain_id', 'enabled', 'idp_id', 'name', 'protocol_id',
|
||||||
|
'unique_id', 'password_expires_at')
|
||||||
|
hints = self.build_driver_hints(filters)
|
||||||
|
ENFORCER.enforce_call(action='identity:list_users', filters=filters)
|
||||||
|
domain = self._get_domain_id_for_list_request()
|
||||||
|
refs = PROVIDERS.identity_api.list_users(
|
||||||
|
domain_scope=domain, hints=hints)
|
||||||
|
return self.wrap_collection(refs, hints=hints)
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
"""Create a user.
|
||||||
|
|
||||||
|
POST /v3/users
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:create_user')
|
||||||
|
user_data = self.request_body_json.get('user', {})
|
||||||
|
validation.lazy_validate(schema.user_create, user_data)
|
||||||
|
user_data = self._normalize_dict(user_data)
|
||||||
|
user_data = self._normalize_domain_id(user_data)
|
||||||
|
ref = PROVIDERS.identity_api.create_user(
|
||||||
|
user_data,
|
||||||
|
initiator=self.audit_initiator)
|
||||||
|
return self.wrap_member(ref), http_client.CREATED
|
||||||
|
|
||||||
|
def patch(self, user_id):
|
||||||
|
"""Update a user.
|
||||||
|
|
||||||
|
PATCH /v3/users/{user_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:update_user')
|
||||||
|
user_data = self.request_body_json.get('user', {})
|
||||||
|
validation.lazy_validate(schema.user_update, user_data)
|
||||||
|
self._require_matching_id(user_data)
|
||||||
|
ref = PROVIDERS.identity_api.update_user(
|
||||||
|
user_id, user_data, initiator=self.audit_initiator)
|
||||||
|
return self.wrap_member(ref)
|
||||||
|
|
||||||
|
def delete(self, user_id):
|
||||||
|
"""Delete a user.
|
||||||
|
|
||||||
|
DELETE /v3/users/{user_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:delete_user')
|
||||||
|
PROVIDERS.identity_api.delete_user(user_id)
|
||||||
|
return None, http_client.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
class UserChangePasswordResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = '__UNUSED__'
|
||||||
|
member_key = '__UNUSED__'
|
||||||
|
|
||||||
|
@ks_flask.unenforced_api
|
||||||
|
def get(self, user_id):
|
||||||
|
# Special case, GET is not allowed.
|
||||||
|
raise exceptions.MethodNotAllowed(valid_methods=['POST'])
|
||||||
|
|
||||||
|
@ks_flask.unenforced_api
|
||||||
|
def post(self, user_id):
|
||||||
|
user_data = self.request_body_json.get('user', {})
|
||||||
|
original_password = user_data.get('original_password')
|
||||||
|
new_password = user_data.get('password')
|
||||||
|
|
||||||
|
# TODO(morgan): Convert this to JSON Schema validation
|
||||||
|
if original_password is None:
|
||||||
|
raise ks_exception.ValidationError(
|
||||||
|
target='user',
|
||||||
|
attribute='original_password')
|
||||||
|
|
||||||
|
# TODO(morgan): Convert this to JSON Schema validation
|
||||||
|
if new_password is None:
|
||||||
|
raise ks_exception.ValidationError(
|
||||||
|
target='user',
|
||||||
|
attribute='password')
|
||||||
|
|
||||||
|
try:
|
||||||
|
PROVIDERS.identity_api.change_password(
|
||||||
|
user_id=user_id,
|
||||||
|
original_password=original_password,
|
||||||
|
new_password=new_password,
|
||||||
|
initiator=self.audit_initiator)
|
||||||
|
except AssertionError as e:
|
||||||
|
raise ks_exception.Unauthorized(
|
||||||
|
_('Error when changing user password: %s') % e
|
||||||
|
)
|
||||||
|
return None, http_client.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
class UserProjectsResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'projects'
|
||||||
|
member_key = 'project'
|
||||||
|
get_member_from_driver = PROVIDERS.deferred_provider_lookup(
|
||||||
|
api='resource_api', method='get_project')
|
||||||
|
|
||||||
|
def get(self, user_id):
|
||||||
|
filters = ('domain_id', 'enabled', 'name')
|
||||||
|
ENFORCER.enforce_call(action='identity:list_user_projects',
|
||||||
|
filters=filters)
|
||||||
|
hints = self.build_driver_hints(filters)
|
||||||
|
refs = PROVIDERS.assignment_api.list_projects_for_user(user_id)
|
||||||
|
return self.wrap_collection(refs, hints=hints)
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroupsResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'groups'
|
||||||
|
member_key = 'group'
|
||||||
|
get_member_from_driver = PROVIDERS.deferred_provider_lookup(
|
||||||
|
api='identity_api', method='get_group')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _built_target_attr_enforcement():
|
||||||
|
ref = {}
|
||||||
|
if flask.request.view_args:
|
||||||
|
ref['user'] = PROVIDERS.identity_api.get_user(
|
||||||
|
flask.request.view_args.get('user_id'))
|
||||||
|
return ref
|
||||||
|
|
||||||
|
def get(self, user_id):
|
||||||
|
"""Get groups for a user.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/groups
|
||||||
|
"""
|
||||||
|
filters = ('name',)
|
||||||
|
hints = self.build_driver_hints(filters)
|
||||||
|
ENFORCER.enforce_call(action='identity:list_groups_for_user',
|
||||||
|
build_target=self._built_target_attr_enforcement,
|
||||||
|
filters=filters)
|
||||||
|
refs = PROVIDERS.identity_api.list_groups_for_user(user_id=user_id,
|
||||||
|
hints=hints)
|
||||||
|
return self.wrap_collection(refs, hints=hints)
|
||||||
|
|
||||||
|
|
||||||
|
class _UserOSEC2CredBaseResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'credentials'
|
||||||
|
member_key = 'credential'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _add_self_referential_link(cls, ref, collection_name=None):
|
||||||
|
# NOTE(morgan): This should be refactored to have an EC2 Cred API with
|
||||||
|
# a sane prefix instead of overloading the "_add_self_referential_link"
|
||||||
|
# method. This was chosen as it more closely mirrors the pre-flask
|
||||||
|
# code (for transition).
|
||||||
|
path = '/users/%(user_id)s/credentials/OS-EC2/%(credential_id)s'
|
||||||
|
|
||||||
|
url = ks_flask.base_url(path) % {
|
||||||
|
'user_id': ref['user_id'],
|
||||||
|
'credential_id': ref['access']}
|
||||||
|
ref.setdefault('links', {})
|
||||||
|
ref['links']['self'] = url
|
||||||
|
|
||||||
|
|
||||||
|
class UserOSEC2CredentialsResourceListCreate(_UserOSEC2CredBaseResource):
|
||||||
|
def get(self, user_id):
|
||||||
|
"""List EC2 Credentials for user.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/credentials/OS-EC2
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:ec2_list_credentials')
|
||||||
|
PROVIDERS.identity_api.get_user(user_id)
|
||||||
|
credential_refs = PROVIDERS.credential_api.list_credentials_for_user(
|
||||||
|
user_id, type=CRED_TYPE_EC2)
|
||||||
|
collection_refs = [
|
||||||
|
_convert_v3_to_ec2_credential(cred)
|
||||||
|
for cred in credential_refs
|
||||||
|
]
|
||||||
|
return self.wrap_collection(collection_refs)
|
||||||
|
|
||||||
|
def post(self, user_id):
|
||||||
|
"""Create EC2 Credential for user.
|
||||||
|
|
||||||
|
POST /v3/users/{user_id}/credentials/OS-EC2
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:ec2_create_credential')
|
||||||
|
PROVIDERS.identity_api.get_user(user_id)
|
||||||
|
tenant_id = self.request_body_json.get('tenant_id')
|
||||||
|
PROVIDERS.resource_api.get_project(tenant_id)
|
||||||
|
blob = dict(
|
||||||
|
access=uuid.uuid4().hex,
|
||||||
|
secret=uuid.uuid4().hex,
|
||||||
|
trust_id=self.oslo_context.trust_id
|
||||||
|
)
|
||||||
|
credential_id = utils.hash_access_key(blob['access'])
|
||||||
|
cred_data = dict(
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=tenant_id,
|
||||||
|
blob=jsonutils.dumps(blob),
|
||||||
|
id=credential_id,
|
||||||
|
type=CRED_TYPE_EC2
|
||||||
|
)
|
||||||
|
PROVIDERS.credential_api.create_credential(credential_id, cred_data)
|
||||||
|
ref = _convert_v3_to_ec2_credential(cred_data)
|
||||||
|
return self.wrap_member(ref), http_client.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
class UserOSEC2CredentialsResourceGetDelete(_UserOSEC2CredBaseResource):
|
||||||
|
@staticmethod
|
||||||
|
def _get_cred_data(credential_id):
|
||||||
|
cred = PROVIDERS.credential_api.get_credential(credential_id)
|
||||||
|
if not cred or cred['type'] != CRED_TYPE_EC2:
|
||||||
|
raise ks_exception.Unauthorized(
|
||||||
|
message=_('EC2 access key not found.'))
|
||||||
|
return _convert_v3_to_ec2_credential(cred)
|
||||||
|
|
||||||
|
def get(self, user_id, credential_id):
|
||||||
|
"""Get a specific EC2 credential.
|
||||||
|
|
||||||
|
GET/HEAD /users/{user_id}/credentials/OS-EC2/{credential_id}
|
||||||
|
"""
|
||||||
|
func = _build_enforcer_target_data_owner_and_user_id_match
|
||||||
|
ENFORCER.enforce_call(
|
||||||
|
action='identity:ec2_get_credential',
|
||||||
|
build_target=func)
|
||||||
|
PROVIDERS.identity_api.get_user(user_id)
|
||||||
|
ec2_cred_id = utils.hash_access_key(credential_id)
|
||||||
|
cred_data = self._get_cred_data(ec2_cred_id)
|
||||||
|
return self.wrap_member(cred_data)
|
||||||
|
|
||||||
|
def delete(self, user_id, credential_id):
|
||||||
|
"""Delete a specific EC2 credential.
|
||||||
|
|
||||||
|
DELETE /users/{user_id}/credentials/OS-EC2/{credential_id}
|
||||||
|
"""
|
||||||
|
func = _build_enforcer_target_data_owner_and_user_id_match
|
||||||
|
ENFORCER.enforce_call(action='identity:ec2_delete_credential',
|
||||||
|
build_target=func)
|
||||||
|
PROVIDERS.identity_api.get_user(user_id)
|
||||||
|
ec2_cred_id = utils.hash_access_key(credential_id)
|
||||||
|
self._get_cred_data(ec2_cred_id)
|
||||||
|
PROVIDERS.credential_api.delete_credential(ec2_cred_id)
|
||||||
|
return None, http_client.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
class _OAuth1ResourceBase(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'access_tokens'
|
||||||
|
member_key = 'access_token'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _add_self_referential_link(cls, ref, collection_name=None):
|
||||||
|
# NOTE(morgan): This should be refactored to have an OAuth1 API with
|
||||||
|
# a sane prefix instead of overloading the "_add_self_referential_link"
|
||||||
|
# method. This was chosen as it more closely mirrors the pre-flask
|
||||||
|
# code (for transition).
|
||||||
|
ref.setdefault('links', {})
|
||||||
|
path = '/users/%(user_id)s/OS-OAUTH1/access_tokens' % {
|
||||||
|
'user_id': ref.get('authorizing_user_id', '')
|
||||||
|
}
|
||||||
|
ref['links']['self'] = ks_flask.base_url(path) + '/' + ref['id']
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth1ListAccessTokensResource(_OAuth1ResourceBase):
|
||||||
|
def get(self, user_id):
|
||||||
|
"""List OAuth1 Access Tokens for user.
|
||||||
|
|
||||||
|
GET /v3/users/{user_id}/OS=OAUTH1/access_tokens
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:list_access_tokens')
|
||||||
|
if self.oslo_context.is_delegated_auth:
|
||||||
|
raise ks_exception.Forbidden(
|
||||||
|
_('Cannot list request tokens with a token '
|
||||||
|
'issued via delegation.'))
|
||||||
|
refs = PROVIDERS.oauth_api.list_access_tokens(user_id)
|
||||||
|
formatted_refs = ([_format_token_entity(x) for x in refs])
|
||||||
|
return self.wrap_collection(formatted_refs)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth1AccessTokenCRUDResource(_OAuth1ResourceBase):
|
||||||
|
def get(self, user_id, access_token_id):
|
||||||
|
"""Get specific access token.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:get_access_token')
|
||||||
|
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
||||||
|
if access_token['authorizing_user_id'] != user_id:
|
||||||
|
raise ks_exception.NotFound()
|
||||||
|
access_token = _format_token_entity(access_token)
|
||||||
|
return self.wrap_member(access_token)
|
||||||
|
|
||||||
|
def delete(self, user_id, access_token_id):
|
||||||
|
"""Delete specific access token.
|
||||||
|
|
||||||
|
DELETE /v3/users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(
|
||||||
|
action='identity:ec2_delete_credential',
|
||||||
|
build_target=_build_enforcer_target_data_owner_and_user_id_match)
|
||||||
|
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
||||||
|
reason = (
|
||||||
|
'Invalidating the token cache because an access token for '
|
||||||
|
'consumer %(consumer_id)s has been deleted. Authorization for '
|
||||||
|
'users with OAuth tokens will be recalculated and enforced '
|
||||||
|
'accordingly the next time they authenticate or validate a '
|
||||||
|
'token.' % {'consumer_id': access_token['consumer_id']}
|
||||||
|
)
|
||||||
|
notifications.invalidate_token_cache_notification(reason)
|
||||||
|
PROVIDERS.oauth_api.delete_access_token(
|
||||||
|
user_id, access_token_id, initiator=self.audit_initiator)
|
||||||
|
return None, http_client.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth1AccessTokenRoleListResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'roles'
|
||||||
|
member_key = 'role'
|
||||||
|
|
||||||
|
def get(self, user_id, access_token_id):
|
||||||
|
"""List roles for a user access token.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/OS-OAUTH1/access_tokens/
|
||||||
|
{access_token_id}/roles
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:list_access_token_roles')
|
||||||
|
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
||||||
|
if access_token['authorizing_user_id'] != user_id:
|
||||||
|
raise ks_exception.NotFound()
|
||||||
|
authed_role_ids = access_token['role_ids']
|
||||||
|
authed_role_ids = jsonutils.loads(authed_role_ids)
|
||||||
|
refs = ([_format_role_entity(x) for x in authed_role_ids])
|
||||||
|
return self.wrap_collection(refs)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth1AccessTokenRoleResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'roles'
|
||||||
|
member_key = 'role'
|
||||||
|
|
||||||
|
def get(self, user_id, access_token_id, role_id):
|
||||||
|
"""Get role for access token.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/OS-OAUTH1/access_tokens/
|
||||||
|
{access_token_id}/roles/{role_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:get_access_token_role')
|
||||||
|
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
||||||
|
if access_token['authorizing_user_id'] != user_id:
|
||||||
|
raise ks_exception.Unauthorized(_('User IDs do not match'))
|
||||||
|
authed_role_ids = access_token['role_ids']
|
||||||
|
authed_role_ids = jsonutils.loads(authed_role_ids)
|
||||||
|
for authed_role_id in authed_role_ids:
|
||||||
|
if authed_role_id == role_id:
|
||||||
|
role = _format_role_entity(role_id)
|
||||||
|
return self.wrap_member(role)
|
||||||
|
raise ks_exception.RoleNotFound(role_id=role_id)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAppCredListCreateResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'application_credentials'
|
||||||
|
member_key = 'application_credential'
|
||||||
|
_public_parameters = frozenset([
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'expires_at',
|
||||||
|
'project_id',
|
||||||
|
'roles',
|
||||||
|
# secret is only exposed after create, it is not stored
|
||||||
|
'secret',
|
||||||
|
'links',
|
||||||
|
'unrestricted'
|
||||||
|
])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_secret():
|
||||||
|
length = 64
|
||||||
|
secret = os.urandom(length)
|
||||||
|
secret = base64.urlsafe_b64encode(secret)
|
||||||
|
secret = secret.rstrip(b'=')
|
||||||
|
secret = secret.decode('utf-8')
|
||||||
|
return secret
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_role_list(app_cred_roles):
|
||||||
|
roles = []
|
||||||
|
for role in app_cred_roles:
|
||||||
|
if role.get('id'):
|
||||||
|
roles.append(role)
|
||||||
|
else:
|
||||||
|
roles.append(PROVIDERS.role_api.get_unique_role_by_name(
|
||||||
|
role['name']))
|
||||||
|
return roles
|
||||||
|
|
||||||
|
def get(self, user_id):
|
||||||
|
"""List application credentials for user.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/application_credentials
|
||||||
|
"""
|
||||||
|
filters = ('name',)
|
||||||
|
ENFORCER.enforce_call(action='identity:list_application_credentials',
|
||||||
|
filters=filters)
|
||||||
|
app_cred_api = PROVIDERS.application_credential_api
|
||||||
|
hints = self.build_driver_hints(filters)
|
||||||
|
refs = app_cred_api.list_application_credentials(user_id, hints=hints)
|
||||||
|
return self.wrap_collection(refs, hints=hints)
|
||||||
|
|
||||||
|
def post(self, user_id):
|
||||||
|
"""Create application credential.
|
||||||
|
|
||||||
|
POST /v3/users/{user_id}/application_credentials
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:create_application_credential')
|
||||||
|
app_cred_data = self.request_body_json.get(
|
||||||
|
'application_credential', {})
|
||||||
|
validation.lazy_validate(app_cred_schema.application_credential_create,
|
||||||
|
app_cred_data)
|
||||||
|
token = self.auth_context['token']
|
||||||
|
_check_unrestricted_application_credential(token)
|
||||||
|
if self.oslo_context.user_id != user_id:
|
||||||
|
action = _('Cannot create an application credential for another '
|
||||||
|
'user.')
|
||||||
|
raise ks_exception.ForbiddenAction(action=action)
|
||||||
|
project_id = self.oslo_context.project_id
|
||||||
|
app_cred_data = self._assign_unique_id(app_cred_data)
|
||||||
|
if not app_cred_data.get('secret'):
|
||||||
|
app_cred_data['secret'] = self._generate_secret()
|
||||||
|
app_cred_data['user_id'] = user_id
|
||||||
|
app_cred_data['project_id'] = project_id
|
||||||
|
app_cred_data['roles'] = self._normalize_role_list(
|
||||||
|
app_cred_data.get('roles', token.roles))
|
||||||
|
if app_cred_data.get('expires_at'):
|
||||||
|
app_cred_data['expires_at'] = utils.parse_expiration_date(
|
||||||
|
app_cred_data['expires_at'])
|
||||||
|
app_cred_data = self._normalize_dict(app_cred_data)
|
||||||
|
app_cred_api = PROVIDERS.application_credential_api
|
||||||
|
|
||||||
|
try:
|
||||||
|
ref = app_cred_api.create_application_credential(
|
||||||
|
app_cred_data, initiator=self.audit_initiator)
|
||||||
|
except ks_exception.RoleAssignmentNotFound as e:
|
||||||
|
# Raise a Bad Request, not a Not Found, in accordance with the
|
||||||
|
# API-SIG recommendations:
|
||||||
|
# https://specs.openstack.org/openstack/api-wg/guidelines/http.html#failure-code-clarifications
|
||||||
|
raise ks_exception.ApplicationCredentialValidationError(
|
||||||
|
detail=str(e))
|
||||||
|
return self.wrap_member(ref), http_client.CREATED
|
||||||
|
|
||||||
|
|
||||||
|
class UserAppCredGetDeleteResource(ks_flask.ResourceBase):
|
||||||
|
collection_key = 'application_credentials'
|
||||||
|
member_key = 'application_credential'
|
||||||
|
|
||||||
|
def get(self, user_id, application_credential_id):
|
||||||
|
"""Get application credential resource.
|
||||||
|
|
||||||
|
GET/HEAD /v3/users/{user_id}/application_credentials/
|
||||||
|
{application_credential_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:get_application_credential')
|
||||||
|
ref = PROVIDERS.application_credential_api.get_application_credential(
|
||||||
|
application_credential_id)
|
||||||
|
return self.wrap_member(ref)
|
||||||
|
|
||||||
|
def delete(self, user_id, application_credential_id):
|
||||||
|
"""Delete application credential resource.
|
||||||
|
|
||||||
|
DELETE /v3/users/{user_id}/application_credentials/
|
||||||
|
{application_credential_id}
|
||||||
|
"""
|
||||||
|
ENFORCER.enforce_call(action='identity:delete_application_credential')
|
||||||
|
token = self.auth_context['token']
|
||||||
|
_check_unrestricted_application_credential(token)
|
||||||
|
PROVIDERS.application_credential_api.delete_application_credential(
|
||||||
|
application_credential_id, initiator=self.audit_initiator)
|
||||||
|
return None, http_client.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
class UserAPI(ks_flask.APIBase):
|
||||||
|
_name = 'users'
|
||||||
|
_import_name = __name__
|
||||||
|
resources = [UserResource]
|
||||||
|
resource_mapping = [
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserChangePasswordResource,
|
||||||
|
url='/users/<string:user_id>/password',
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_change_password',
|
||||||
|
path_vars={'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserGroupsResource,
|
||||||
|
url='/users/<string:user_id>/groups',
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_groups',
|
||||||
|
path_vars={'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserProjectsResource,
|
||||||
|
url='/users/<string:user_id>/projects',
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_projects',
|
||||||
|
path_vars={'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserOSEC2CredentialsResourceListCreate,
|
||||||
|
url='/users/<string:user_id>/credentials/OS-EC2',
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_credentials',
|
||||||
|
resource_relation_func=(
|
||||||
|
json_home_relations.os_ec2_resource_rel_func),
|
||||||
|
path_vars={'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserOSEC2CredentialsResourceGetDelete,
|
||||||
|
url=('/users/<string:user_id>/credentials/OS-EC2/'
|
||||||
|
'<string:credential_id>'),
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_credential',
|
||||||
|
resource_relation_func=(
|
||||||
|
json_home_relations.os_ec2_resource_rel_func),
|
||||||
|
path_vars={
|
||||||
|
'credential_id': json_home.build_v3_parameter_relation(
|
||||||
|
'credential_id'),
|
||||||
|
'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=OAuth1ListAccessTokensResource,
|
||||||
|
url='/users/<string:user_id>/OS-OAUTH1/access_tokens',
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_access_tokens',
|
||||||
|
resource_relation_func=(
|
||||||
|
json_home_relations.os_oauth1_resource_rel_func),
|
||||||
|
path_vars={'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=OAuth1AccessTokenCRUDResource,
|
||||||
|
url=('/users/<string:user_id>/OS-OAUTH1/'
|
||||||
|
'access_tokens/<string:access_token_id>'),
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_access_token',
|
||||||
|
resource_relation_func=(
|
||||||
|
json_home_relations.os_oauth1_resource_rel_func),
|
||||||
|
path_vars={
|
||||||
|
'access_token_id': ACCESS_TOKEN_ID_PARAMETER_RELATION,
|
||||||
|
'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=OAuth1AccessTokenRoleListResource,
|
||||||
|
url=('/users/<string:user_id>/OS-OAUTH1/access_tokens/'
|
||||||
|
'<string:access_token_id>/roles'),
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_access_token_roles',
|
||||||
|
resource_relation_func=(
|
||||||
|
json_home_relations.os_oauth1_resource_rel_func),
|
||||||
|
path_vars={'access_token_id': ACCESS_TOKEN_ID_PARAMETER_RELATION,
|
||||||
|
'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=OAuth1AccessTokenRoleResource,
|
||||||
|
url=('/users/<string:user_id>/OS-OAUTH1/access_tokens/'
|
||||||
|
'<string:access_token_id>/roles/<string:role_id>'),
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='user_access_token_role',
|
||||||
|
resource_relation_func=(
|
||||||
|
json_home_relations.os_oauth1_resource_rel_func),
|
||||||
|
path_vars={'access_token_id': ACCESS_TOKEN_ID_PARAMETER_RELATION,
|
||||||
|
'role_id': json_home.Parameters.ROLE_ID,
|
||||||
|
'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserAppCredListCreateResource,
|
||||||
|
url='/users/<string:user_id>/application_credentials',
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='application_credentials',
|
||||||
|
path_vars={'user_id': json_home.Parameters.USER_ID}
|
||||||
|
),
|
||||||
|
ks_flask.construct_resource_map(
|
||||||
|
resource=UserAppCredGetDeleteResource,
|
||||||
|
url=('/users/<string:user_id>/application_credentials/'
|
||||||
|
'<string:application_credential_id>'),
|
||||||
|
resource_kwargs={},
|
||||||
|
rel='application_credential',
|
||||||
|
path_vars={
|
||||||
|
'user_id': json_home.Parameters.USER_ID,
|
||||||
|
'application_credential_id':
|
||||||
|
json_home.Parameters.APPLICATION_CRED_ID}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
APIs = (UserAPI,)
|
|
@ -10,5 +10,4 @@
|
||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
from keystone.application_credential import controllers # noqa
|
|
||||||
from keystone.application_credential.core import * # noqa
|
from keystone.application_credential.core import * # noqa
|
||||||
|
|
|
@ -1,153 +0,0 @@
|
||||||
# Copyright 2018 SUSE Linux GmbH
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
"""Workflow Logic the Application Credential service."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
from oslo_log import log
|
|
||||||
|
|
||||||
from keystone.application_credential import schema
|
|
||||||
from keystone.common import controller
|
|
||||||
from keystone.common import provider_api
|
|
||||||
from keystone.common import utils
|
|
||||||
from keystone.common import validation
|
|
||||||
import keystone.conf
|
|
||||||
from keystone import exception
|
|
||||||
from keystone.i18n import _
|
|
||||||
|
|
||||||
|
|
||||||
CONF = keystone.conf.CONF
|
|
||||||
LOG = log.getLogger(__name__)
|
|
||||||
PROVIDERS = provider_api.ProviderAPIs
|
|
||||||
|
|
||||||
|
|
||||||
class ApplicationCredentialV3(controller.V3Controller):
|
|
||||||
collection_name = 'application_credentials'
|
|
||||||
member_name = 'application_credential'
|
|
||||||
_public_parameters = frozenset([
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'expires_at',
|
|
||||||
'project_id',
|
|
||||||
'roles',
|
|
||||||
# secret is only exposed after create, it is not stored
|
|
||||||
'secret',
|
|
||||||
'links',
|
|
||||||
'unrestricted'
|
|
||||||
])
|
|
||||||
|
|
||||||
def _normalize_role_list(self, app_cred_roles):
|
|
||||||
roles = []
|
|
||||||
for role in app_cred_roles:
|
|
||||||
if role.get('id'):
|
|
||||||
roles.append(role)
|
|
||||||
else:
|
|
||||||
roles.append(PROVIDERS.role_api.get_unique_role_by_name(
|
|
||||||
role['name']))
|
|
||||||
return roles
|
|
||||||
|
|
||||||
def _generate_secret(self):
|
|
||||||
length = 64
|
|
||||||
secret = os.urandom(length)
|
|
||||||
secret = base64.urlsafe_b64encode(secret)
|
|
||||||
secret = secret.rstrip(b'=')
|
|
||||||
secret = secret.decode('utf-8')
|
|
||||||
return secret
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _add_self_referential_link(cls, context, ref):
|
|
||||||
path = ('/users/%(user_id)s/application_credentials') % {
|
|
||||||
'user_id': ref['user_id']}
|
|
||||||
ref.setdefault('links', {})
|
|
||||||
ref['links']['self'] = cls.base_url(
|
|
||||||
context, path=path) + '/' + ref['id']
|
|
||||||
return ref
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def wrap_member(cls, context, ref):
|
|
||||||
cls._add_self_referential_link(context, ref)
|
|
||||||
ref = cls.filter_params(ref)
|
|
||||||
return {cls.member_name: ref}
|
|
||||||
|
|
||||||
def _check_unrestricted(self, token):
|
|
||||||
if 'application_credential' in token.methods:
|
|
||||||
if not token.application_credential['unrestricted']:
|
|
||||||
action = _("Using method 'application_credential' is not "
|
|
||||||
"allowed for managing additional application "
|
|
||||||
"credentials.")
|
|
||||||
raise exception.ForbiddenAction(action=action)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def create_application_credential(self, request, user_id,
|
|
||||||
application_credential):
|
|
||||||
validation.lazy_validate(schema.application_credential_create,
|
|
||||||
application_credential)
|
|
||||||
|
|
||||||
token = request.auth_context['token']
|
|
||||||
self._check_unrestricted(token)
|
|
||||||
if request.context.user_id != user_id:
|
|
||||||
action = _("Cannot create an application credential for another "
|
|
||||||
"user")
|
|
||||||
raise exception.ForbiddenAction(action=action)
|
|
||||||
project_id = request.context.project_id
|
|
||||||
app_cred = self._assign_unique_id(application_credential)
|
|
||||||
if not app_cred.get('secret'):
|
|
||||||
app_cred['secret'] = self._generate_secret()
|
|
||||||
app_cred['user_id'] = user_id
|
|
||||||
app_cred['project_id'] = project_id
|
|
||||||
app_cred['roles'] = self._normalize_role_list(
|
|
||||||
app_cred.get('roles', token.roles))
|
|
||||||
if app_cred.get('expires_at'):
|
|
||||||
app_cred['expires_at'] = utils.parse_expiration_date(
|
|
||||||
app_cred['expires_at'])
|
|
||||||
app_cred = self._normalize_dict(app_cred)
|
|
||||||
app_cred_api = PROVIDERS.application_credential_api
|
|
||||||
try:
|
|
||||||
ref = app_cred_api.create_application_credential(
|
|
||||||
app_cred, initiator=request.audit_initiator
|
|
||||||
)
|
|
||||||
except exception.RoleAssignmentNotFound as e:
|
|
||||||
# Raise a Bad Request, not a Not Found, in accordance with the
|
|
||||||
# API-SIG recommendations:
|
|
||||||
# https://specs.openstack.org/openstack/api-wg/guidelines/http.html#failure-code-clarifications
|
|
||||||
raise exception.ApplicationCredentialValidationError(
|
|
||||||
detail=str(e))
|
|
||||||
return ApplicationCredentialV3.wrap_member(request.context_dict, ref)
|
|
||||||
|
|
||||||
@controller.filterprotected('name')
|
|
||||||
def list_application_credentials(self, request, filters, user_id):
|
|
||||||
app_cred_api = PROVIDERS.application_credential_api
|
|
||||||
hints = ApplicationCredentialV3.build_driver_hints(request, filters)
|
|
||||||
refs = app_cred_api.list_application_credentials(user_id, hints=hints)
|
|
||||||
return ApplicationCredentialV3.wrap_collection(request.context_dict,
|
|
||||||
refs)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def get_application_credential(self, request, user_id,
|
|
||||||
application_credential_id):
|
|
||||||
ref = PROVIDERS.application_credential_api.get_application_credential(
|
|
||||||
application_credential_id)
|
|
||||||
return ApplicationCredentialV3.wrap_member(request.context_dict, ref)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def delete_application_credential(self, request, user_id,
|
|
||||||
application_credential_id):
|
|
||||||
token = request.auth_context['token']
|
|
||||||
self._check_unrestricted(token)
|
|
||||||
PROVIDERS.application_credential_api.delete_application_credential(
|
|
||||||
application_credential_id, initiator=request.audit_initiator
|
|
||||||
)
|
|
|
@ -1,55 +0,0 @@
|
||||||
# Copyright 2018 SUSE Linux GmbH
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
"""WSGI Routers for the Application Credential service."""
|
|
||||||
|
|
||||||
from keystone.application_credential import controllers
|
|
||||||
from keystone.common import json_home
|
|
||||||
from keystone.common import wsgi
|
|
||||||
|
|
||||||
APP_CRED_RESOURCE_RELATION = json_home.build_v3_resource_relation(
|
|
||||||
'application_credential')
|
|
||||||
APP_CRED_PARAMETER_RELATION = json_home.build_v3_parameter_relation(
|
|
||||||
'application_credential_id')
|
|
||||||
APP_CRED_COLLECTION_PATH = '/users/{user_id}/application_credentials'
|
|
||||||
APP_CRED_RESOURCE_PATH = (
|
|
||||||
'/users/{user_id}/application_credentials/{application_credential_id}'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Routers(wsgi.RoutersBase):
|
|
||||||
_path_prefixes = (APP_CRED_COLLECTION_PATH, 'users',)
|
|
||||||
|
|
||||||
def append_v3_routers(self, mapper, routers):
|
|
||||||
app_cred_controller = controllers.ApplicationCredentialV3()
|
|
||||||
|
|
||||||
self._add_resource(
|
|
||||||
mapper, app_cred_controller,
|
|
||||||
path=APP_CRED_COLLECTION_PATH,
|
|
||||||
get_head_action='list_application_credentials',
|
|
||||||
post_action='create_application_credential',
|
|
||||||
rel=APP_CRED_RESOURCE_RELATION,
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
self._add_resource(
|
|
||||||
mapper, app_cred_controller,
|
|
||||||
path=APP_CRED_RESOURCE_PATH,
|
|
||||||
get_head_action='get_application_credential',
|
|
||||||
delete_action='delete_application_credential',
|
|
||||||
rel=APP_CRED_RESOURCE_RELATION,
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
'application_credential_id': APP_CRED_PARAMETER_RELATION,
|
|
||||||
})
|
|
|
@ -31,25 +31,6 @@ LOG = log.getLogger(__name__)
|
||||||
PROVIDERS = provider_api.ProviderAPIs
|
PROVIDERS = provider_api.ProviderAPIs
|
||||||
|
|
||||||
|
|
||||||
class ProjectAssignmentV3(controller.V3Controller):
|
|
||||||
"""The V3 Project APIs that are processing assignments."""
|
|
||||||
|
|
||||||
collection_name = 'projects'
|
|
||||||
member_name = 'project'
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super(ProjectAssignmentV3, self).__init__()
|
|
||||||
self.get_member_from_driver = PROVIDERS.resource_api.get_project
|
|
||||||
|
|
||||||
@controller.filterprotected('domain_id', 'enabled', 'name')
|
|
||||||
def list_user_projects(self, request, filters, user_id):
|
|
||||||
hints = ProjectAssignmentV3.build_driver_hints(request, filters)
|
|
||||||
refs = PROVIDERS.assignment_api.list_projects_for_user(user_id)
|
|
||||||
return ProjectAssignmentV3.wrap_collection(request.context_dict,
|
|
||||||
refs,
|
|
||||||
hints=hints)
|
|
||||||
|
|
||||||
|
|
||||||
class GrantAssignmentV3(controller.V3Controller):
|
class GrantAssignmentV3(controller.V3Controller):
|
||||||
"""The V3 Grant Assignment APIs."""
|
"""The V3 Grant Assignment APIs."""
|
||||||
|
|
||||||
|
|
|
@ -20,31 +20,12 @@ from keystone.common import json_home
|
||||||
from keystone.common import wsgi
|
from keystone.common import wsgi
|
||||||
|
|
||||||
|
|
||||||
class Public(wsgi.ComposableRouter):
|
|
||||||
def add_routes(self, mapper):
|
|
||||||
tenant_controller = controllers.TenantAssignment()
|
|
||||||
mapper.connect('/tenants',
|
|
||||||
controller=tenant_controller,
|
|
||||||
action='get_projects_for_token',
|
|
||||||
conditions=dict(method=['GET']))
|
|
||||||
|
|
||||||
|
|
||||||
class Routers(wsgi.RoutersBase):
|
class Routers(wsgi.RoutersBase):
|
||||||
|
|
||||||
_path_prefixes = ('users', 'projects')
|
_path_prefixes = ('projects',)
|
||||||
|
|
||||||
def append_v3_routers(self, mapper, routers):
|
def append_v3_routers(self, mapper, routers):
|
||||||
|
|
||||||
project_controller = controllers.ProjectAssignmentV3()
|
|
||||||
self._add_resource(
|
|
||||||
mapper, project_controller,
|
|
||||||
path='/users/{user_id}/projects',
|
|
||||||
get_head_action='list_user_projects',
|
|
||||||
rel=json_home.build_v3_resource_relation('user_projects'),
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
grant_controller = controllers.GrantAssignmentV3()
|
grant_controller = controllers.GrantAssignmentV3()
|
||||||
self._add_resource(
|
self._add_resource(
|
||||||
mapper, grant_controller,
|
mapper, grant_controller,
|
||||||
|
|
|
@ -57,6 +57,8 @@ class Parameters(object):
|
||||||
TAG_VALUE = build_v3_parameter_relation('tag_value')
|
TAG_VALUE = build_v3_parameter_relation('tag_value')
|
||||||
REGISTERED_LIMIT_ID = build_v3_parameter_relation('registered_limit_id')
|
REGISTERED_LIMIT_ID = build_v3_parameter_relation('registered_limit_id')
|
||||||
LIMIT_ID = build_v3_parameter_relation('limit_id')
|
LIMIT_ID = build_v3_parameter_relation('limit_id')
|
||||||
|
APPLICATION_CRED_ID = build_v3_parameter_relation(
|
||||||
|
'application_credential_id')
|
||||||
|
|
||||||
|
|
||||||
class Status(object):
|
class Status(object):
|
||||||
|
|
|
@ -34,7 +34,6 @@ Glance to list images needed to perform the requested task.
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
|
||||||
|
|
||||||
from keystoneclient.contrib.ec2 import utils as ec2_utils
|
from keystoneclient.contrib.ec2 import utils as ec2_utils
|
||||||
from oslo_serialization import jsonutils
|
from oslo_serialization import jsonutils
|
||||||
|
@ -156,70 +155,6 @@ class Ec2ControllerCommon(provider_api.ProviderAPIMixin, object):
|
||||||
|
|
||||||
return user_ref, tenant_ref, roles_ref
|
return user_ref, tenant_ref, roles_ref
|
||||||
|
|
||||||
def create_credential(self, request, user_id, tenant_id):
|
|
||||||
"""Create a secret/access pair for use with ec2 style auth.
|
|
||||||
|
|
||||||
Generates a new set of credentials that map the user/tenant
|
|
||||||
pair.
|
|
||||||
|
|
||||||
:param request: current request
|
|
||||||
:param user_id: id of user
|
|
||||||
:param tenant_id: id of tenant
|
|
||||||
:returns: credential: dict of ec2 credential
|
|
||||||
"""
|
|
||||||
self.identity_api.get_user(user_id)
|
|
||||||
self.resource_api.get_project(tenant_id)
|
|
||||||
blob = {'access': uuid.uuid4().hex,
|
|
||||||
'secret': uuid.uuid4().hex,
|
|
||||||
'trust_id': request.context.trust_id}
|
|
||||||
credential_id = utils.hash_access_key(blob['access'])
|
|
||||||
cred_ref = {'user_id': user_id,
|
|
||||||
'project_id': tenant_id,
|
|
||||||
'blob': jsonutils.dumps(blob),
|
|
||||||
'id': credential_id,
|
|
||||||
'type': CRED_TYPE_EC2}
|
|
||||||
self.credential_api.create_credential(credential_id, cred_ref)
|
|
||||||
return {'credential': self._convert_v3_to_ec2_credential(cred_ref)}
|
|
||||||
|
|
||||||
def get_credentials(self, user_id):
|
|
||||||
"""List all credentials for a user.
|
|
||||||
|
|
||||||
:param user_id: id of user
|
|
||||||
:returns: credentials: list of ec2 credential dicts
|
|
||||||
"""
|
|
||||||
self.identity_api.get_user(user_id)
|
|
||||||
credential_refs = self.credential_api.list_credentials_for_user(
|
|
||||||
user_id, type=CRED_TYPE_EC2)
|
|
||||||
return {'credentials':
|
|
||||||
[self._convert_v3_to_ec2_credential(credential)
|
|
||||||
for credential in credential_refs]}
|
|
||||||
|
|
||||||
def get_credential(self, user_id, credential_id):
|
|
||||||
"""Retrieve a user's access/secret pair by the access key.
|
|
||||||
|
|
||||||
Grab the full access/secret pair for a given access key.
|
|
||||||
|
|
||||||
:param user_id: id of user
|
|
||||||
:param credential_id: access key for credentials
|
|
||||||
:returns: credential: dict of ec2 credential
|
|
||||||
"""
|
|
||||||
self.identity_api.get_user(user_id)
|
|
||||||
return {'credential': self._get_credentials(credential_id)}
|
|
||||||
|
|
||||||
def delete_credential(self, user_id, credential_id):
|
|
||||||
"""Delete a user's access/secret pair.
|
|
||||||
|
|
||||||
Used to revoke a user's access/secret pair
|
|
||||||
|
|
||||||
:param user_id: id of user
|
|
||||||
:param credential_id: access key for credentials
|
|
||||||
:returns: bool: success
|
|
||||||
"""
|
|
||||||
self.identity_api.get_user(user_id)
|
|
||||||
self._get_credentials(credential_id)
|
|
||||||
ec2_credential_id = utils.hash_access_key(credential_id)
|
|
||||||
return self.credential_api.delete_credential(ec2_credential_id)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _convert_v3_to_ec2_credential(credential):
|
def _convert_v3_to_ec2_credential(credential):
|
||||||
# Prior to bug #1259584 fix, blob was stored unserialized
|
# Prior to bug #1259584 fix, blob was stored unserialized
|
||||||
|
@ -271,22 +206,6 @@ class Ec2ControllerV3(Ec2ControllerCommon, controller.V3Controller):
|
||||||
collection_name = 'credentials'
|
collection_name = 'credentials'
|
||||||
member_name = 'credential'
|
member_name = 'credential'
|
||||||
|
|
||||||
def _check_credential_owner_and_user_id_match(self, request, prep_info,
|
|
||||||
user_id, credential_id):
|
|
||||||
# NOTE(morganfainberg): this method needs to capture the arguments of
|
|
||||||
# the method that is decorated with @controller.protected() (with
|
|
||||||
# exception of the first argument ('context') since the protected
|
|
||||||
# method passes in *args, **kwargs. In this case, it is easier to see
|
|
||||||
# the expected input if the argspec is `user_id` and `credential_id`
|
|
||||||
# explicitly (matching the :class:`.ec2_delete_credential()` method
|
|
||||||
# below).
|
|
||||||
ref = {}
|
|
||||||
credential_id = utils.hash_access_key(credential_id)
|
|
||||||
ref['credential'] = self.credential_api.get_credential(credential_id)
|
|
||||||
# NOTE(morganfainberg): policy_api is required for this
|
|
||||||
# check_protection to properly be able to perform policy enforcement.
|
|
||||||
self.check_protection(request, prep_info, ref)
|
|
||||||
|
|
||||||
def authenticate(self, context, credentials=None, ec2Credentials=None):
|
def authenticate(self, context, credentials=None, ec2Credentials=None):
|
||||||
(user_ref, project_ref, roles_ref) = self._authenticate(
|
(user_ref, project_ref, roles_ref) = self._authenticate(
|
||||||
credentials=credentials, ec2credentials=ec2Credentials
|
credentials=credentials, ec2credentials=ec2Credentials
|
||||||
|
@ -299,37 +218,3 @@ class Ec2ControllerV3(Ec2ControllerCommon, controller.V3Controller):
|
||||||
)
|
)
|
||||||
token_reference = render_token.render_token_response_from_model(token)
|
token_reference = render_token.render_token_response_from_model(token)
|
||||||
return self.render_token_data_response(token.id, token_reference)
|
return self.render_token_data_response(token.id, token_reference)
|
||||||
|
|
||||||
@controller.protected(callback=_check_credential_owner_and_user_id_match)
|
|
||||||
def ec2_get_credential(self, request, user_id, credential_id):
|
|
||||||
ref = super(Ec2ControllerV3, self).get_credential(user_id,
|
|
||||||
credential_id)
|
|
||||||
return Ec2ControllerV3.wrap_member(request.context_dict,
|
|
||||||
ref['credential'])
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def ec2_list_credentials(self, request, user_id):
|
|
||||||
refs = super(Ec2ControllerV3, self).get_credentials(user_id)
|
|
||||||
return Ec2ControllerV3.wrap_collection(request.context_dict,
|
|
||||||
refs['credentials'])
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def ec2_create_credential(self, request, user_id, tenant_id):
|
|
||||||
ref = super(Ec2ControllerV3, self).create_credential(
|
|
||||||
request, user_id, tenant_id)
|
|
||||||
return Ec2ControllerV3.wrap_member(request.context_dict,
|
|
||||||
ref['credential'])
|
|
||||||
|
|
||||||
@controller.protected(callback=_check_credential_owner_and_user_id_match)
|
|
||||||
def ec2_delete_credential(self, request, user_id, credential_id):
|
|
||||||
return super(Ec2ControllerV3, self).delete_credential(user_id,
|
|
||||||
credential_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _add_self_referential_link(cls, context, ref):
|
|
||||||
path = '/users/%(user_id)s/credentials/OS-EC2/%(credential_id)s'
|
|
||||||
url = cls.base_url(context, path) % {
|
|
||||||
'user_id': ref['user_id'],
|
|
||||||
'credential_id': ref['access']}
|
|
||||||
ref.setdefault('links', {})
|
|
||||||
ref['links']['self'] = url
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ build_resource_relation = functools.partial(
|
||||||
|
|
||||||
class Routers(wsgi.RoutersBase):
|
class Routers(wsgi.RoutersBase):
|
||||||
|
|
||||||
_path_prefixes = ('ec2tokens', 'users')
|
_path_prefixes = ('ec2tokens',)
|
||||||
|
|
||||||
def append_v3_routers(self, mapper, routers):
|
def append_v3_routers(self, mapper, routers):
|
||||||
ec2_controller = controllers.Ec2ControllerV3()
|
ec2_controller = controllers.Ec2ControllerV3()
|
||||||
|
@ -36,25 +36,3 @@ class Routers(wsgi.RoutersBase):
|
||||||
path='/ec2tokens',
|
path='/ec2tokens',
|
||||||
post_action='authenticate',
|
post_action='authenticate',
|
||||||
rel=build_resource_relation(resource_name='ec2tokens'))
|
rel=build_resource_relation(resource_name='ec2tokens'))
|
||||||
|
|
||||||
# crud
|
|
||||||
self._add_resource(
|
|
||||||
mapper, ec2_controller,
|
|
||||||
path='/users/{user_id}/credentials/OS-EC2',
|
|
||||||
get_head_action='ec2_list_credentials',
|
|
||||||
post_action='ec2_create_credential',
|
|
||||||
rel=build_resource_relation(resource_name='user_credentials'),
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
self._add_resource(
|
|
||||||
mapper, ec2_controller,
|
|
||||||
path='/users/{user_id}/credentials/OS-EC2/{credential_id}',
|
|
||||||
get_head_action='ec2_get_credential',
|
|
||||||
delete_action='ec2_delete_credential',
|
|
||||||
rel=build_resource_relation(resource_name='user_credential'),
|
|
||||||
path_vars={
|
|
||||||
'credential_id':
|
|
||||||
json_home.build_v3_parameter_relation('credential_id'),
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
|
|
|
@ -12,6 +12,5 @@
|
||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
from keystone.identity import controllers # noqa
|
|
||||||
from keystone.identity.core import * # noqa
|
from keystone.identity.core import * # noqa
|
||||||
from keystone.identity import generator # noqa
|
from keystone.identity import generator # noqa
|
||||||
|
|
|
@ -1,134 +0,0 @@
|
||||||
# Copyright 2012 OpenStack Foundation
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
"""Workflow Logic the Identity service."""
|
|
||||||
|
|
||||||
from oslo_log import log
|
|
||||||
|
|
||||||
from keystone.common import controller
|
|
||||||
from keystone.common import provider_api
|
|
||||||
from keystone.common import validation
|
|
||||||
import keystone.conf
|
|
||||||
from keystone import exception
|
|
||||||
from keystone.i18n import _
|
|
||||||
from keystone.identity import schema
|
|
||||||
|
|
||||||
|
|
||||||
CONF = keystone.conf.CONF
|
|
||||||
LOG = log.getLogger(__name__)
|
|
||||||
PROVIDERS = provider_api.ProviderAPIs
|
|
||||||
|
|
||||||
|
|
||||||
class UserV3(controller.V3Controller):
|
|
||||||
collection_name = 'users'
|
|
||||||
member_name = 'user'
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super(UserV3, self).__init__()
|
|
||||||
self.get_member_from_driver = PROVIDERS.identity_api.get_user
|
|
||||||
|
|
||||||
def _check_user_and_group_protection(self, request, prep_info,
|
|
||||||
user_id, group_id):
|
|
||||||
ref = {}
|
|
||||||
ref['user'] = PROVIDERS.identity_api.get_user(user_id)
|
|
||||||
ref['group'] = PROVIDERS.identity_api.get_group(group_id)
|
|
||||||
self.check_protection(request, prep_info, ref)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def create_user(self, request, user):
|
|
||||||
validation.lazy_validate(schema.user_create, user)
|
|
||||||
# The manager layer will generate the unique ID for users
|
|
||||||
ref = self._normalize_dict(user)
|
|
||||||
ref = self._normalize_domain_id(request, ref)
|
|
||||||
ref = PROVIDERS.identity_api.create_user(
|
|
||||||
ref, initiator=request.audit_initiator
|
|
||||||
)
|
|
||||||
return UserV3.wrap_member(request.context_dict, ref)
|
|
||||||
|
|
||||||
@controller.filterprotected('domain_id', 'enabled', 'idp_id', 'name',
|
|
||||||
'protocol_id', 'unique_id',
|
|
||||||
'password_expires_at')
|
|
||||||
def list_users(self, request, filters):
|
|
||||||
hints = UserV3.build_driver_hints(request, filters)
|
|
||||||
domain = self._get_domain_id_for_list_request(request)
|
|
||||||
refs = PROVIDERS.identity_api.list_users(
|
|
||||||
domain_scope=domain, hints=hints
|
|
||||||
)
|
|
||||||
return UserV3.wrap_collection(request.context_dict, refs, hints=hints)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def get_user(self, request, user_id):
|
|
||||||
ref = PROVIDERS.identity_api.get_user(user_id)
|
|
||||||
return UserV3.wrap_member(request.context_dict, ref)
|
|
||||||
|
|
||||||
def _update_user(self, request, user_id, user):
|
|
||||||
self._require_matching_id(user_id, user)
|
|
||||||
ref = PROVIDERS.identity_api.update_user(
|
|
||||||
user_id, user, initiator=request.audit_initiator
|
|
||||||
)
|
|
||||||
return UserV3.wrap_member(request.context_dict, ref)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def update_user(self, request, user_id, user):
|
|
||||||
validation.lazy_validate(schema.user_update, user)
|
|
||||||
return self._update_user(request, user_id, user)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def delete_user(self, request, user_id):
|
|
||||||
return PROVIDERS.identity_api.delete_user(
|
|
||||||
user_id, initiator=request.audit_initiator
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE(gagehugo): We do not need this to be @protected.
|
|
||||||
# A user is already expected to know their password in order
|
|
||||||
# to change it, and can be authenticated as such.
|
|
||||||
def change_password(self, request, user_id, user):
|
|
||||||
original_password = user.get('original_password')
|
|
||||||
if original_password is None:
|
|
||||||
raise exception.ValidationError(target='user',
|
|
||||||
attribute='original_password')
|
|
||||||
|
|
||||||
password = user.get('password')
|
|
||||||
if password is None:
|
|
||||||
raise exception.ValidationError(target='user',
|
|
||||||
attribute='password')
|
|
||||||
try:
|
|
||||||
PROVIDERS.identity_api.change_password(
|
|
||||||
user_id, original_password,
|
|
||||||
password, initiator=request.audit_initiator)
|
|
||||||
except AssertionError as e:
|
|
||||||
raise exception.Unauthorized(_(
|
|
||||||
'Error when changing user password: %s') % e)
|
|
||||||
|
|
||||||
|
|
||||||
class GroupV3(controller.V3Controller):
|
|
||||||
collection_name = 'groups'
|
|
||||||
member_name = 'group'
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super(GroupV3, self).__init__()
|
|
||||||
self.get_member_from_driver = PROVIDERS.identity_api.get_group
|
|
||||||
|
|
||||||
def _check_user_protection(self, request, prep_info, user_id):
|
|
||||||
ref = {}
|
|
||||||
ref['user'] = PROVIDERS.identity_api.get_user(user_id)
|
|
||||||
self.check_protection(request, prep_info, ref)
|
|
||||||
|
|
||||||
@controller.filterprotected('name', callback=_check_user_protection)
|
|
||||||
def list_groups_for_user(self, request, filters, user_id):
|
|
||||||
hints = GroupV3.build_driver_hints(request, filters)
|
|
||||||
refs = PROVIDERS.identity_api.list_groups_for_user(
|
|
||||||
user_id, hints=hints
|
|
||||||
)
|
|
||||||
return GroupV3.wrap_collection(request.context_dict, refs, hints=hints)
|
|
|
@ -899,20 +899,10 @@ class Manager(manager.Manager):
|
||||||
# - select the right driver for this domain
|
# - select the right driver for this domain
|
||||||
# - clear/set domain_ids for drivers that do not support domains
|
# - clear/set domain_ids for drivers that do not support domains
|
||||||
# - create any ID mapping that might be required
|
# - create any ID mapping that might be required
|
||||||
|
|
||||||
# TODO(morgan): The split of "authenticate" and "_authenticate" is done
|
|
||||||
# until user API is converted to flask. This is to make webob and flask
|
|
||||||
# play nicely with the authenticate mechanism during self-service password
|
|
||||||
# changes. While this is in place, CADF notifications will not be emitted
|
|
||||||
# for self-service password changes indicating an auth attempt was being
|
|
||||||
# made. This is a very limited time transitional change.
|
|
||||||
@notifications.emit_event('authenticate')
|
@notifications.emit_event('authenticate')
|
||||||
def authenticate(self, user_id, password):
|
|
||||||
return self._authenticate(user_id, password)
|
|
||||||
|
|
||||||
@domains_configured
|
@domains_configured
|
||||||
@exception_translated('assertion')
|
@exception_translated('assertion')
|
||||||
def _authenticate(self, user_id, password):
|
def authenticate(self, user_id, password):
|
||||||
domain_id, driver, entity_id = (
|
domain_id, driver, entity_id = (
|
||||||
self._get_domain_driver_and_entity_id(user_id))
|
self._get_domain_driver_and_entity_id(user_id))
|
||||||
ref = driver.authenticate(entity_id, password)
|
ref = driver.authenticate(entity_id, password)
|
||||||
|
@ -1390,9 +1380,7 @@ class Manager(manager.Manager):
|
||||||
|
|
||||||
# authenticate() will raise an AssertionError if authentication fails
|
# authenticate() will raise an AssertionError if authentication fails
|
||||||
try:
|
try:
|
||||||
# TODO(morgan): When users is ported to flask, ensure this is
|
self.authenticate(user_id, original_password)
|
||||||
# mapped back to self.authenticate instead of self._authenticate.
|
|
||||||
self._authenticate(user_id, original_password)
|
|
||||||
except exception.PasswordExpired:
|
except exception.PasswordExpired:
|
||||||
# If a password has expired, we want users to be able to change it
|
# If a password has expired, we want users to be able to change it
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
# Copyright 2012 OpenStack Foundation
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
"""WSGI Routers for the Identity service."""
|
|
||||||
|
|
||||||
from keystone.common import json_home
|
|
||||||
from keystone.common import router
|
|
||||||
from keystone.common import wsgi
|
|
||||||
from keystone.identity import controllers
|
|
||||||
|
|
||||||
|
|
||||||
class Routers(wsgi.RoutersBase):
|
|
||||||
|
|
||||||
_path_prefixes = ('users', )
|
|
||||||
|
|
||||||
def append_v3_routers(self, mapper, routers):
|
|
||||||
user_controller = controllers.UserV3()
|
|
||||||
routers.append(
|
|
||||||
router.Router(user_controller,
|
|
||||||
'users', 'user',
|
|
||||||
resource_descriptions=self.v3_resources))
|
|
||||||
|
|
||||||
self._add_resource(
|
|
||||||
mapper, user_controller,
|
|
||||||
path='/users/{user_id}/password',
|
|
||||||
post_action='change_password',
|
|
||||||
rel=json_home.build_v3_resource_relation('user_change_password'),
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
group_controller = controllers.GroupV3()
|
|
||||||
|
|
||||||
self._add_resource(
|
|
||||||
mapper, group_controller,
|
|
||||||
path='/users/{user_id}/groups',
|
|
||||||
get_head_action='list_groups_for_user',
|
|
||||||
rel=json_home.build_v3_resource_relation('user_groups'),
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
|
@ -1,143 +0,0 @@
|
||||||
# Copyright 2013 OpenStack Foundation
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
"""Extensions supporting OAuth1."""
|
|
||||||
|
|
||||||
from oslo_log import log
|
|
||||||
from oslo_serialization import jsonutils
|
|
||||||
|
|
||||||
from keystone.common import controller
|
|
||||||
from keystone.common import provider_api
|
|
||||||
import keystone.conf
|
|
||||||
from keystone import exception
|
|
||||||
from keystone.i18n import _
|
|
||||||
from keystone import notifications
|
|
||||||
|
|
||||||
|
|
||||||
CONF = keystone.conf.CONF
|
|
||||||
LOG = log.getLogger(__name__)
|
|
||||||
PROVIDERS = provider_api.ProviderAPIs
|
|
||||||
|
|
||||||
|
|
||||||
class AccessTokenCrudV3(controller.V3Controller):
|
|
||||||
collection_name = 'access_tokens'
|
|
||||||
member_name = 'access_token'
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _add_self_referential_link(cls, context, ref):
|
|
||||||
# NOTE(lwolf): overriding method to add proper path to self link
|
|
||||||
ref.setdefault('links', {})
|
|
||||||
path = '/users/%(user_id)s/OS-OAUTH1/access_tokens' % {
|
|
||||||
'user_id': cls._get_user_id(ref)
|
|
||||||
}
|
|
||||||
ref['links']['self'] = cls.base_url(context, path) + '/' + ref['id']
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def get_access_token(self, request, user_id, access_token_id):
|
|
||||||
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
|
||||||
if access_token['authorizing_user_id'] != user_id:
|
|
||||||
raise exception.NotFound()
|
|
||||||
access_token = self._format_token_entity(request.context_dict,
|
|
||||||
access_token)
|
|
||||||
return AccessTokenCrudV3.wrap_member(request.context_dict,
|
|
||||||
access_token)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def list_access_tokens(self, request, user_id):
|
|
||||||
if request.context.is_delegated_auth:
|
|
||||||
raise exception.Forbidden(
|
|
||||||
_('Cannot list request tokens'
|
|
||||||
' with a token issued via delegation.'))
|
|
||||||
refs = PROVIDERS.oauth_api.list_access_tokens(user_id)
|
|
||||||
formatted_refs = ([self._format_token_entity(request.context_dict, x)
|
|
||||||
for x in refs])
|
|
||||||
return AccessTokenCrudV3.wrap_collection(request.context_dict,
|
|
||||||
formatted_refs)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def delete_access_token(self, request, user_id, access_token_id):
|
|
||||||
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
|
||||||
reason = (
|
|
||||||
'Invalidating the token cache because an access token for '
|
|
||||||
'consumer %(consumer_id)s has been deleted. Authorization for '
|
|
||||||
'users with OAuth tokens will be recalculated and enforced '
|
|
||||||
'accordingly the next time they authenticate or validate a '
|
|
||||||
'token.' % {'consumer_id': access_token['consumer_id']}
|
|
||||||
)
|
|
||||||
notifications.invalidate_token_cache_notification(reason)
|
|
||||||
return PROVIDERS.oauth_api.delete_access_token(
|
|
||||||
user_id, access_token_id, initiator=request.audit_initiator
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_user_id(entity):
|
|
||||||
return entity.get('authorizing_user_id', '')
|
|
||||||
|
|
||||||
def _format_token_entity(self, context, entity):
|
|
||||||
|
|
||||||
formatted_entity = entity.copy()
|
|
||||||
access_token_id = formatted_entity['id']
|
|
||||||
user_id = self._get_user_id(formatted_entity)
|
|
||||||
if 'role_ids' in entity:
|
|
||||||
formatted_entity.pop('role_ids')
|
|
||||||
if 'access_secret' in entity:
|
|
||||||
formatted_entity.pop('access_secret')
|
|
||||||
|
|
||||||
url = ('/users/%(user_id)s/OS-OAUTH1/access_tokens/%(access_token_id)s'
|
|
||||||
'/roles' % {'user_id': user_id,
|
|
||||||
'access_token_id': access_token_id})
|
|
||||||
|
|
||||||
formatted_entity.setdefault('links', {})
|
|
||||||
formatted_entity['links']['roles'] = (self.base_url(context, url))
|
|
||||||
|
|
||||||
return formatted_entity
|
|
||||||
|
|
||||||
|
|
||||||
class AccessTokenRolesV3(controller.V3Controller):
|
|
||||||
collection_name = 'roles'
|
|
||||||
member_name = 'role'
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def list_access_token_roles(self, request, user_id, access_token_id):
|
|
||||||
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
|
||||||
if access_token['authorizing_user_id'] != user_id:
|
|
||||||
raise exception.NotFound()
|
|
||||||
authed_role_ids = access_token['role_ids']
|
|
||||||
authed_role_ids = jsonutils.loads(authed_role_ids)
|
|
||||||
refs = ([self._format_role_entity(x) for x in authed_role_ids])
|
|
||||||
return AccessTokenRolesV3.wrap_collection(request.context_dict, refs)
|
|
||||||
|
|
||||||
@controller.protected()
|
|
||||||
def get_access_token_role(self, request, user_id,
|
|
||||||
access_token_id, role_id):
|
|
||||||
access_token = PROVIDERS.oauth_api.get_access_token(access_token_id)
|
|
||||||
if access_token['authorizing_user_id'] != user_id:
|
|
||||||
raise exception.Unauthorized(_('User IDs do not match'))
|
|
||||||
authed_role_ids = access_token['role_ids']
|
|
||||||
authed_role_ids = jsonutils.loads(authed_role_ids)
|
|
||||||
for authed_role_id in authed_role_ids:
|
|
||||||
if authed_role_id == role_id:
|
|
||||||
role = self._format_role_entity(role_id)
|
|
||||||
return AccessTokenRolesV3.wrap_member(request.context_dict,
|
|
||||||
role)
|
|
||||||
raise exception.RoleNotFound(role_id=role_id)
|
|
||||||
|
|
||||||
def _format_role_entity(self, role_id):
|
|
||||||
role = PROVIDERS.role_api.get_role(role_id)
|
|
||||||
formatted_entity = role.copy()
|
|
||||||
if 'description' in role:
|
|
||||||
formatted_entity.pop('description')
|
|
||||||
if 'enabled' in role:
|
|
||||||
formatted_entity.pop('enabled')
|
|
||||||
return formatted_entity
|
|
|
@ -1,101 +0,0 @@
|
||||||
# Copyright 2013 OpenStack Foundation
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import functools
|
|
||||||
|
|
||||||
from keystone.common import json_home
|
|
||||||
from keystone.common import wsgi
|
|
||||||
from keystone.oauth1 import controllers
|
|
||||||
|
|
||||||
|
|
||||||
build_resource_relation = functools.partial(
|
|
||||||
json_home.build_v3_extension_resource_relation,
|
|
||||||
extension_name='OS-OAUTH1', extension_version='1.0')
|
|
||||||
|
|
||||||
build_parameter_relation = functools.partial(
|
|
||||||
json_home.build_v3_extension_parameter_relation,
|
|
||||||
extension_name='OS-OAUTH1', extension_version='1.0')
|
|
||||||
|
|
||||||
ACCESS_TOKEN_ID_PARAMETER_RELATION = build_parameter_relation(
|
|
||||||
parameter_name='access_token_id')
|
|
||||||
|
|
||||||
|
|
||||||
class Routers(wsgi.RoutersBase):
|
|
||||||
"""API Endpoints for the OAuth1 extension.
|
|
||||||
|
|
||||||
The goal of this extension is to allow third-party service providers
|
|
||||||
to acquire tokens with a limited subset of a user's roles for acting
|
|
||||||
on behalf of that user. This is done using an oauth-similar flow and
|
|
||||||
api.
|
|
||||||
|
|
||||||
The API looks like::
|
|
||||||
|
|
||||||
# User access token crud
|
|
||||||
GET /users/{user_id}/OS-OAUTH1/access_tokens
|
|
||||||
GET /users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}
|
|
||||||
GET /users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}/roles
|
|
||||||
GET /users/{user_id}/OS-OAUTH1/access_tokens
|
|
||||||
/{access_token_id}/roles/{role_id}
|
|
||||||
DELETE /users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
_path_prefixes = ('users',)
|
|
||||||
|
|
||||||
def append_v3_routers(self, mapper, routers):
|
|
||||||
access_token_controller = controllers.AccessTokenCrudV3()
|
|
||||||
access_token_roles_controller = controllers.AccessTokenRolesV3()
|
|
||||||
|
|
||||||
# user access token crud
|
|
||||||
self._add_resource(
|
|
||||||
mapper, access_token_controller,
|
|
||||||
path='/users/{user_id}/OS-OAUTH1/access_tokens',
|
|
||||||
get_head_action='list_access_tokens',
|
|
||||||
rel=build_resource_relation(resource_name='user_access_tokens'),
|
|
||||||
path_vars={
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
self._add_resource(
|
|
||||||
mapper, access_token_controller,
|
|
||||||
path='/users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}',
|
|
||||||
get_head_action='get_access_token',
|
|
||||||
delete_action='delete_access_token',
|
|
||||||
rel=build_resource_relation(resource_name='user_access_token'),
|
|
||||||
path_vars={
|
|
||||||
'access_token_id': ACCESS_TOKEN_ID_PARAMETER_RELATION,
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
self._add_resource(
|
|
||||||
mapper, access_token_roles_controller,
|
|
||||||
path='/users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}/'
|
|
||||||
'roles',
|
|
||||||
get_head_action='list_access_token_roles',
|
|
||||||
rel=build_resource_relation(
|
|
||||||
resource_name='user_access_token_roles'),
|
|
||||||
path_vars={
|
|
||||||
'access_token_id': ACCESS_TOKEN_ID_PARAMETER_RELATION,
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
||||||
self._add_resource(
|
|
||||||
mapper, access_token_roles_controller,
|
|
||||||
path='/users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id}/'
|
|
||||||
'roles/{role_id}',
|
|
||||||
get_head_action='get_access_token_role',
|
|
||||||
rel=build_resource_relation(
|
|
||||||
resource_name='user_access_token_role'),
|
|
||||||
path_vars={
|
|
||||||
'access_token_id': ACCESS_TOKEN_ID_PARAMETER_RELATION,
|
|
||||||
'role_id': json_home.Parameters.ROLE_ID,
|
|
||||||
'user_id': json_home.Parameters.USER_ID,
|
|
||||||
})
|
|
|
@ -24,13 +24,10 @@ import routes
|
||||||
import werkzeug.wsgi
|
import werkzeug.wsgi
|
||||||
|
|
||||||
import keystone.api
|
import keystone.api
|
||||||
from keystone.application_credential import routers as app_cred_routers
|
|
||||||
from keystone.assignment import routers as assignment_routers
|
from keystone.assignment import routers as assignment_routers
|
||||||
from keystone.common import wsgi as keystone_wsgi
|
from keystone.common import wsgi as keystone_wsgi
|
||||||
from keystone.contrib.ec2 import routers as ec2_routers
|
from keystone.contrib.ec2 import routers as ec2_routers
|
||||||
from keystone.contrib.s3 import routers as s3_routers
|
from keystone.contrib.s3 import routers as s3_routers
|
||||||
from keystone.identity import routers as identity_routers
|
|
||||||
from keystone.oauth1 import routers as oauth1_routers
|
|
||||||
from keystone.resource import routers as resource_routers
|
from keystone.resource import routers as resource_routers
|
||||||
|
|
||||||
# TODO(morgan): _MOVED_API_PREFIXES to be removed when the legacy dispatch
|
# TODO(morgan): _MOVED_API_PREFIXES to be removed when the legacy dispatch
|
||||||
|
@ -57,6 +54,7 @@ _MOVED_API_PREFIXES = frozenset(
|
||||||
'roles',
|
'roles',
|
||||||
'services',
|
'services',
|
||||||
'system',
|
'system',
|
||||||
|
'users',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -64,10 +62,7 @@ LOG = log.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
ALL_API_ROUTERS = [assignment_routers,
|
ALL_API_ROUTERS = [assignment_routers,
|
||||||
identity_routers,
|
|
||||||
app_cred_routers,
|
|
||||||
resource_routers,
|
resource_routers,
|
||||||
oauth1_routers,
|
|
||||||
ec2_routers,
|
ec2_routers,
|
||||||
s3_routers]
|
s3_routers]
|
||||||
|
|
||||||
|
|
|
@ -921,7 +921,7 @@ class ResourceBase(flask_restful.Resource):
|
||||||
if token_ref.domain_scoped:
|
if token_ref.domain_scoped:
|
||||||
return token_ref.domain_id
|
return token_ref.domain_id
|
||||||
elif token_ref.project_scoped:
|
elif token_ref.project_scoped:
|
||||||
return token_ref.project_domain_id
|
return token_ref.project_domain['id']
|
||||||
else:
|
else:
|
||||||
msg = 'No domain information specified as part of list request'
|
msg = 'No domain information specified as part of list request'
|
||||||
tr_msg = _('No domain information specified as part of list '
|
tr_msg = _('No domain information specified as part of list '
|
||||||
|
@ -941,7 +941,8 @@ class ResourceBase(flask_restful.Resource):
|
||||||
# Retrieve the auth context that was prepared by
|
# Retrieve the auth context that was prepared by
|
||||||
# AuthContextMiddleware.
|
# AuthContextMiddleware.
|
||||||
|
|
||||||
auth_context = cls.auth_context
|
auth_context = flask.request.environ.get(
|
||||||
|
authorization.AUTH_CONTEXT_ENV, {})
|
||||||
return auth_context['token']
|
return auth_context['token']
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOG.warning("Couldn't find the auth context.")
|
LOG.warning("Couldn't find the auth context.")
|
||||||
|
|
|
@ -292,15 +292,26 @@ class ApplicationCredentialTestCase(test_v3.RestfulTestCase):
|
||||||
expected_status=http_client.NO_CONTENT)
|
expected_status=http_client.NO_CONTENT)
|
||||||
|
|
||||||
def test_update_application_credential(self):
|
def test_update_application_credential(self):
|
||||||
roles = [{'id': self.role_id}]
|
with self.test_client() as c:
|
||||||
app_cred_body = self._app_cred_body(roles=roles)
|
roles = [{'id': self.role_id}]
|
||||||
resp = self.post('/users/%s/application_credentials' % self.user_id,
|
app_cred_body = self._app_cred_body(roles=roles)
|
||||||
body=app_cred_body,
|
token = self.get_scoped_token()
|
||||||
expected_status=http_client.CREATED)
|
resp = c.post(
|
||||||
# Application credentials are immutable
|
'/v3/users/%s/application_credentials' % self.user_id,
|
||||||
app_cred_body['application_credential']['description'] = "New Things"
|
json=app_cred_body,
|
||||||
app_cred_id = resp.json['application_credential']['id']
|
expected_status_code=http_client.CREATED,
|
||||||
self.patch(MEMBER_PATH_FMT % {'user_id': self.user_id,
|
headers={'X-Auth-Token': token})
|
||||||
'app_cred_id': app_cred_id},
|
# Application credentials are immutable
|
||||||
body=app_cred_body,
|
app_cred_body['application_credential'][
|
||||||
expected_status=http_client.NOT_FOUND)
|
'description'] = "New Things"
|
||||||
|
app_cred_id = resp.json['application_credential']['id']
|
||||||
|
# NOTE(morgan): when the whole test case is converted to using
|
||||||
|
# flask test_client, this extra v3 prefix will
|
||||||
|
# need to be rolled into the base MEMBER_PATH_FMT
|
||||||
|
member_path = '/v3%s' % MEMBER_PATH_FMT % {
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'app_cred_id': app_cred_id}
|
||||||
|
c.patch(member_path,
|
||||||
|
json=app_cred_body,
|
||||||
|
expected_status_code=http_client.METHOD_NOT_ALLOWED,
|
||||||
|
headers={'X-Auth-Token': token})
|
||||||
|
|
|
@ -142,6 +142,7 @@ FEDERATED_IDP_SPECIFIC_WEBSSO = ('/auth/OS-FEDERATION/identity_providers/'
|
||||||
|
|
||||||
APPLICATION_CREDENTIAL = ('/users/{user_id}/application_credentials/'
|
APPLICATION_CREDENTIAL = ('/users/{user_id}/application_credentials/'
|
||||||
'{application_credential_id}')
|
'{application_credential_id}')
|
||||||
|
APPLICATION_CREDENTIALS = '/users/{user_id}/application_credentials'
|
||||||
APPLICATION_CREDENTIAL_RELATION = (
|
APPLICATION_CREDENTIAL_RELATION = (
|
||||||
json_home.build_v3_parameter_relation('application_credential_id'))
|
json_home.build_v3_parameter_relation('application_credential_id'))
|
||||||
|
|
||||||
|
@ -633,6 +634,10 @@ V3_JSON_HOME_RESOURCES = {
|
||||||
'href': '/limits/model',
|
'href': '/limits/model',
|
||||||
'hints': {'status': 'experimental'}
|
'hints': {'status': 'experimental'}
|
||||||
},
|
},
|
||||||
|
json_home.build_v3_resource_relation('application_credentials'): {
|
||||||
|
'href-template': APPLICATION_CREDENTIALS,
|
||||||
|
'href-vars': {
|
||||||
|
'user_id': json_home.build_v3_parameter_relation('user_id')}},
|
||||||
json_home.build_v3_resource_relation('application_credential'): {
|
json_home.build_v3_resource_relation('application_credential'): {
|
||||||
'href-template': APPLICATION_CREDENTIAL,
|
'href-template': APPLICATION_CREDENTIAL,
|
||||||
'href-vars': {
|
'href-vars': {
|
||||||
|
|
Loading…
Reference in New Issue