rest: string → UUID conversion for resource.id to be unique per user

This changes the UUID5 based mechanism so it depends on the user trying
to CRUD the resource. This makes sure that when using this kind of
transformation, the resource id is converted to a unique id for the
user, while preventing conflicting if every user wants to create a
"foobar" resource.

Change-Id: Iebaf3b9f8e0a198af0156008710e0c1253dc5f9d
Closes-Bug: #1617918
This commit is contained in:
Julien Danjou 2016-12-19 12:18:09 +01:00
parent d86f6f1f7a
commit ad4b851c7f
9 changed files with 123 additions and 64 deletions

View File

@ -13,7 +13,7 @@
# under the License. # under the License.
# #
"""no-more-slash """Remove slashes from original resource IDs, recompute their id with creator
Revision ID: 397987e38570 Revision ID: 397987e38570
Revises: aba5a217ca9b Revises: aba5a217ca9b
@ -49,7 +49,8 @@ resource_table = sa.Table(
sqlalchemy_utils.types.uuid.UUIDType(), sqlalchemy_utils.types.uuid.UUIDType(),
nullable=False), nullable=False),
sa.Column('original_resource_id', sa.String(255)), sa.Column('original_resource_id', sa.String(255)),
sa.Column('type', sa.String(255)) sa.Column('type', sa.String(255)),
sa.Column('creator', sa.String(255))
) )
resourcehistory_table = sa.Table( resourcehistory_table = sa.Table(
@ -100,15 +101,28 @@ def upgrade():
nullable=False), nullable=False),
) )
for resource in connection.execute(resource_table.select().where( for resource in connection.execute(resource_table.select()):
resource_table.c.original_resource_id.like('%/%'))):
if resource_table.c.original_resource_id is None:
# statsd resource has no original_resource_id and is NULL
continue
try:
orig_as_uuid = uuid.UUID(
str(resource_table.c.original_resource_id))
except ValueError:
pass
else:
if orig_as_uuid == resource_table.c.id:
continue
new_original_resource_id = resource.original_resource_id.replace( new_original_resource_id = resource.original_resource_id.replace(
'/', '_') '/', '_')
if six.PY2: if six.PY2:
new_original_resource_id = new_original_resource_id.encode('utf-8') new_original_resource_id = new_original_resource_id.encode('utf-8')
new_id = sa.literal(uuidtype.process_bind_param( new_id = sa.literal(uuidtype.process_bind_param(
str(uuid.uuid5(utils.RESOURCE_ID_NAMESPACE, str(utils.ResourceUUID(
new_original_resource_id)), new_original_resource_id, resource.creator)),
connection.dialect)) connection.dialect))
# resource table # resource table

View File

@ -14,6 +14,7 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import functools
import itertools import itertools
import uuid import uuid
@ -848,8 +849,10 @@ class ResourceController(rest.RestController):
def __init__(self, resource_type, id): def __init__(self, resource_type, id):
self._resource_type = resource_type self._resource_type = resource_type
creator = pecan.request.auth_helper.get_current_user(
pecan.request.headers)
try: try:
self.id = utils.ResourceUUID(id) self.id = utils.ResourceUUID(id, creator)
except ValueError: except ValueError:
abort(404, indexer.NoSuchResource(id)) abort(404, indexer.NoSuchResource(id))
self.metric = NamedMetricController(str(self.id), self._resource_type) self.metric = NamedMetricController(str(self.id), self._resource_type)
@ -929,8 +932,15 @@ def schema_for(resource_type):
return ResourceSchema(resource_type.schema) return ResourceSchema(resource_type.schema)
def ResourceID(value): def ResourceUUID(value, creator):
return (six.text_type(value), utils.ResourceUUID(value)) try:
return utils.ResourceUUID(value, creator)
except ValueError as e:
raise voluptuous.Invalid(e)
def ResourceID(value, creator):
return (six.text_type(value), ResourceUUID(value, creator))
class ResourcesController(rest.RestController): class ResourcesController(rest.RestController):
@ -946,7 +956,9 @@ class ResourcesController(rest.RestController):
# NOTE(sileht): we need to copy the dict because when change it # NOTE(sileht): we need to copy the dict because when change it
# and we don't want that next patch call have the "id" # and we don't want that next patch call have the "id"
schema = dict(schema_for(self._resource_type)) schema = dict(schema_for(self._resource_type))
schema["id"] = ResourceID creator = pecan.request.auth_helper.get_current_user(
pecan.request.headers)
schema["id"] = functools.partial(ResourceID, creator=creator)
body = deserialize_and_validate(schema) body = deserialize_and_validate(schema)
body["original_resource_id"], body["id"] = body["id"] body["original_resource_id"], body["id"] = body["id"]
@ -956,8 +968,6 @@ class ResourcesController(rest.RestController):
} }
target.update(body) target.update(body)
enforce("create resource", target) enforce("create resource", target)
creator = pecan.request.auth_helper.get_current_user(
pecan.request.headers)
rid = body['id'] rid = body['id']
del body['id'] del body['id']
try: try:
@ -1003,8 +1013,7 @@ class ResourcesController(rest.RestController):
def delete(self, **kwargs): def delete(self, **kwargs):
# NOTE(sileht): Don't allow empty filter, this is going to delete # NOTE(sileht): Don't allow empty filter, this is going to delete
# the entire database. # the entire database.
attr_filter = deserialize_and_validate( attr_filter = deserialize_and_validate(ResourceSearchSchema)
SearchResourceTypeController.ResourceSearchSchema)
# the voluptuous checks everything, but it is better to # the voluptuous checks everything, but it is better to
# have this here. # have this here.
@ -1129,16 +1138,16 @@ class QueryStringSearchAttrFilter(object):
return cls._parsed_query2dict(parsed_query) return cls._parsed_query2dict(parsed_query)
def _ResourceSearchSchema(v): def ResourceSearchSchema(v):
"""Helper method to indirect the recursivity of the search schema""" return _ResourceSearchSchema()(v)
return SearchResourceTypeController.ResourceSearchSchema(v)
class SearchResourceTypeController(rest.RestController): def _ResourceSearchSchema():
def __init__(self, resource_type): user = pecan.request.auth_helper.get_current_user(
self._resource_type = resource_type pecan.request.headers)
_ResourceUUID = functools.partial(ResourceUUID, creator=user)
ResourceSearchSchema = voluptuous.Schema( return voluptuous.Schema(
voluptuous.All( voluptuous.All(
voluptuous.Length(min=0, max=1), voluptuous.Length(min=0, max=1),
{ {
@ -1155,31 +1164,36 @@ class SearchResourceTypeController(rest.RestController):
voluptuous.Length(min=1, max=1), voluptuous.Length(min=1, max=1),
voluptuous.Any( voluptuous.Any(
{"id": voluptuous.Any( {"id": voluptuous.Any(
utils.ResourceUUID, [utils.ResourceUUID]), [_ResourceUUID], _ResourceUUID),
voluptuous.Extra: voluptuous.Extra})), voluptuous.Extra: voluptuous.Extra})),
voluptuous.Any( voluptuous.Any(
u"and", u"", u"and", u"",
u"or", u"", u"or", u"",
u"not", u"not",
): voluptuous.All( ): voluptuous.All(
[_ResourceSearchSchema], voluptuous.Length(min=1) [ResourceSearchSchema], voluptuous.Length(min=1)
) )
} }
) )
) )
@classmethod
def parse_and_validate_qs_filter(cls, query): class SearchResourceTypeController(rest.RestController):
def __init__(self, resource_type):
self._resource_type = resource_type
@staticmethod
def parse_and_validate_qs_filter(query):
try: try:
attr_filter = QueryStringSearchAttrFilter.parse(query) attr_filter = QueryStringSearchAttrFilter.parse(query)
except InvalidQueryStringSearchAttrFilter as e: except InvalidQueryStringSearchAttrFilter as e:
raise abort(400, e) raise abort(400, e)
return voluptuous.Schema(cls.ResourceSearchSchema, return voluptuous.Schema(ResourceSearchSchema,
required=True)(attr_filter) required=True)(attr_filter)
def _search(self, **kwargs): def _search(self, **kwargs):
if pecan.request.body: if pecan.request.body:
attr_filter = deserialize_and_validate(self.ResourceSearchSchema) attr_filter = deserialize_and_validate(ResourceSearchSchema)
elif kwargs.get("filter"): elif kwargs.get("filter"):
attr_filter = self.parse_and_validate_qs_filter(kwargs["filter"]) attr_filter = self.parse_and_validate_qs_filter(kwargs["filter"])
else: else:
@ -1328,13 +1342,16 @@ class SearchMetricController(rest.RestController):
class ResourcesMetricsMeasuresBatchController(rest.RestController): class ResourcesMetricsMeasuresBatchController(rest.RestController):
MeasuresBatchSchema = voluptuous.Schema(
{ResourceID: {six.text_type: MeasuresListSchema}}
)
@pecan.expose('json') @pecan.expose('json')
def post(self, create_metrics=False): def post(self, create_metrics=False):
body = deserialize_and_validate(self.MeasuresBatchSchema) creator = pecan.request.auth_helper.get_current_user(
pecan.request.headers)
MeasuresBatchSchema = voluptuous.Schema(
{functools.partial(ResourceID, creator=creator):
{six.text_type: MeasuresListSchema}}
)
body = deserialize_and_validate(MeasuresBatchSchema)
known_metrics = [] known_metrics = []
unknown_metrics = [] unknown_metrics = []
@ -1349,8 +1366,6 @@ class ResourcesMetricsMeasuresBatchController(rest.RestController):
known_names = [m.name for m in metrics] known_names = [m.name for m in metrics]
if strutils.bool_from_string(create_metrics): if strutils.bool_from_string(create_metrics):
creator = pecan.request.auth_helper.get_current_user(
pecan.request.headers)
already_exists_names = [] already_exists_names = []
for name in names: for name in names:
if name not in known_names: if name not in known_names:

View File

@ -134,13 +134,13 @@ tests:
project_id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea project_id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
status: 201 status: 201
response_headers: response_headers:
location: $SCHEME://$NETLOC/v1/resource/generic/8d835270-2834-5e55-a693-fd0cf91cba3d location: $SCHEME://$NETLOC/v1/resource/generic/2d869568-70d4-5ed6-9891-7d7a3bbf572d
response_json_paths: response_json_paths:
type: generic type: generic
started_at: "2014-01-03T02:02:02+00:00" started_at: "2014-01-03T02:02:02+00:00"
project_id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea project_id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
created_by_project_id: 99d13f22-3618-4288-82b8-6512ded77e4f created_by_project_id: 99d13f22-3618-4288-82b8-6512ded77e4f
id: 8d835270-2834-5e55-a693-fd0cf91cba3d id: 2d869568-70d4-5ed6-9891-7d7a3bbf572d
original_resource_id: 1.2.3.4 original_resource_id: 1.2.3.4
- name: get status denied - name: get status denied

View File

@ -244,7 +244,7 @@ tests:
response_json_paths: response_json_paths:
$.description.cause: "Unknown resources" $.description.cause: "Unknown resources"
$.description.detail: $.description.detail:
- resource_id: "301dbf9a-4fce-52b6-9010-4484c469dcec" - resource_id: "6b8e287d-c01a-538c-979b-a819ee49de5d"
original_resource_id: "foobar" original_resource_id: "foobar"
- name: push measurements to named metrics and resource with create_metrics with wrong measure objects - name: push measurements to named metrics and resource with create_metrics with wrong measure objects

View File

@ -6,6 +6,11 @@
fixtures: fixtures:
- ConfigFixture - ConfigFixture
defaults:
request_headers:
x-user-id: 0fbb231484614b1a80131fc22f6afc9c
x-project-id: f3d41b770cc14f0bb94a1d5be9c0e3ea
tests: tests:
- name: list resource type - name: list resource type
@ -222,8 +227,6 @@ tests:
- name: post invalid resource - name: post invalid resource
POST: /v1/resource/my_custom_resource POST: /v1/resource/my_custom_resource
request_headers: request_headers:
x-user-id: 0fbb2314-8461-4b1a-8013-1fc22f6afc9c
x-project-id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
content-type: application/json content-type: application/json
data: data:
id: d11edfca-4393-4fda-b94d-b05a3a1b3747 id: d11edfca-4393-4fda-b94d-b05a3a1b3747
@ -239,8 +242,6 @@ tests:
- name: post invalid resource uuid - name: post invalid resource uuid
POST: $LAST_URL POST: $LAST_URL
request_headers: request_headers:
x-user-id: 0fbb2314-8461-4b1a-8013-1fc22f6afc9c
x-project-id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
content-type: application/json content-type: application/json
data: data:
id: d11edfca-4393-4fda-b94d-b05a3a1b3747 id: d11edfca-4393-4fda-b94d-b05a3a1b3747
@ -258,8 +259,6 @@ tests:
- name: post custom resource - name: post custom resource
POST: $LAST_URL POST: $LAST_URL
request_headers: request_headers:
x-user-id: 0fbb2314-8461-4b1a-8013-1fc22f6afc9c
x-project-id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
content-type: application/json content-type: application/json
data: data:
id: d11edfca-4393-4fda-b94d-b05a3a1b3747 id: d11edfca-4393-4fda-b94d-b05a3a1b3747
@ -276,8 +275,6 @@ tests:
- name: patch custom resource - name: patch custom resource
PATCH: /v1/resource/my_custom_resource/d11edfca-4393-4fda-b94d-b05a3a1b3747 PATCH: /v1/resource/my_custom_resource/d11edfca-4393-4fda-b94d-b05a3a1b3747
request_headers: request_headers:
x-user-id: 0fbb2314-8461-4b1a-8013-1fc22f6afc9c
x-project-id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
content-type: application/json content-type: application/json
data: data:
name: foo name: foo
@ -303,8 +300,6 @@ tests:
- name: post resource with default - name: post resource with default
POST: /v1/resource/my_custom_resource POST: /v1/resource/my_custom_resource
request_headers: request_headers:
x-user-id: 0fbb2314-8461-4b1a-8013-1fc22f6afc9c
x-project-id: f3d41b77-0cc1-4f0b-b94a-1d5be9c0e3ea
content-type: application/json content-type: application/json
data: data:
id: c4110aec-6e5c-43fa-b8c5-ffdfbca3ce59 id: c4110aec-6e5c-43fa-b8c5-ffdfbca3ce59

View File

@ -66,8 +66,26 @@ tests:
project_id: f3d41b770cc14f0bb94a1d5be9c0e3ea project_id: f3d41b770cc14f0bb94a1d5be9c0e3ea
status: 400 status: 400
response_strings: response_strings:
- "Invalid input: not a valid value for dictionary value @ data[" - "'/' is not supported in resource id"
- "'id'] "
- name: post new resource non uuid again different user
POST: /v1/resource/generic
request_headers:
x-user-id: 0fbb231484614b1a80131fc22f6afc9b
x-project-id: f3d41b770cc14f0bb94a1d5be9c0e3ea
data:
id: generic zero
metrics:
cpu.util:
archive_policy_name: medium
status: 201
response_json_paths:
created_by_user_id: 0fbb231484614b1a80131fc22f6afc9b
created_by_project_id: f3d41b770cc14f0bb94a1d5be9c0e3ea
response_headers:
# is a UUID
location: /v1/resource/generic/[a-f0-9-]{36}/
- name: post new resource non uuid - name: post new resource non uuid
POST: /v1/resource/generic POST: /v1/resource/generic
@ -151,7 +169,7 @@ tests:
archive_policy_name: medium archive_policy_name: medium
status: 400 status: 400
response_strings: response_strings:
- not a valid value for - transformable resource id >255 max allowed characters for dictionary value
- name: post long non uuid resource id - name: post long non uuid resource id
POST: $LAST_URL POST: $LAST_URL

View File

@ -39,23 +39,24 @@ LOG = log.getLogger(__name__)
RESOURCE_ID_NAMESPACE = uuid.UUID('0a7a15ff-aa13-4ac2-897c-9bdf30ce175b') RESOURCE_ID_NAMESPACE = uuid.UUID('0a7a15ff-aa13-4ac2-897c-9bdf30ce175b')
def ResourceUUID(value): def ResourceUUID(value, creator):
if isinstance(value, uuid.UUID): if isinstance(value, uuid.UUID):
return value return value
if '/' in value: if '/' in value:
raise ValueError("'/' is not supported in resource id") raise ValueError("'/' is not supported in resource id")
try: try:
try: return uuid.UUID(value)
return uuid.UUID(value) except ValueError:
except ValueError: if len(value) <= 255:
if len(value) <= 255: # value/creator must be str (unicode) in Python 3 and str (bytes)
if six.PY2: # in Python 2. It's not logical, I know.
value = value.encode('utf-8') if six.PY2:
return uuid.uuid5(RESOURCE_ID_NAMESPACE, value) value = value.encode('utf-8')
raise ValueError( creator = creator.encode('utf-8')
'transformable resource id >255 max allowed characters') return uuid.uuid5(RESOURCE_ID_NAMESPACE,
except Exception as e: value + "\x00" + creator)
raise ValueError(e) raise ValueError(
'transformable resource id >255 max allowed characters')
def UUID(value): def UUID(value):

View File

@ -0,0 +1,12 @@
---
issues:
- >-
The conversion mechanism provided by the API to convert non-UUID resource
id to UUID is now also based on the user creating/accessing the resource.
This makes sure that the conversion generates a unique UUID for the user
and that several users can use the same string as `original_resource_id`.
upgrade:
- >-
Since `original_resource_id` is now unique per creator, that means users
cannot refer to resource by using the `original_resource_id` if the
resource was not created by them.

View File

@ -109,12 +109,16 @@ RESOURCE_IDS=(
"5a301761-cccc-46e2-8900-8b4f6fe6675a" "5a301761-cccc-46e2-8900-8b4f6fe6675a"
) )
# NOTE(sileht): / are now _ # NOTE(sileht): / are now _
[ "$have_resource_type_post" ] && RESOURCE_ID_EXT="5a301761_dddd_46e2_8900_8b4f6fe6675a" # NOTE(jdanjou): and we reencode for admin:admin, but we cannot authenticate as
# admin:admin in basic since ":" is forbidden in any username, so let's use the direct
# computed ID
[ "$have_resource_type_post" ] && RESOURCE_ID_EXT="517920a9-2e50-58b8-88e8-25fd7aae1d8f"
dump_data $GNOCCHI_DATA/new dump_data $GNOCCHI_DATA/new
# NOTE(sileht): change the output of the old gnocchi to compare with the new without '/' # NOTE(sileht): change the output of the old gnocchi to compare with the new without '/'
$GSED -i -e "s,5a301761/dddd/46e2/8900/8b4f6fe6675a,5a301761_dddd_46e2_8900_8b4f6fe6675a,g" \ $GSED -i -e "s,5a301761/dddd/46e2/8900/8b4f6fe6675a,5a301761_dddd_46e2_8900_8b4f6fe6675a,g" \
-e "s,19235bb9-35ca-5f55-b7db-165cfb033c86,fe1bdabf-d94c-5b3a-af1e-06bdff53f228,g" $GNOCCHI_DATA/old/resources.list -e "s,19235bb9-35ca-5f55-b7db-165cfb033c86,517920a9-2e50-58b8-88e8-25fd7aae1d8f,g" $GNOCCHI_DATA/old/resources.list
echo "* Checking output difference between Gnocchi $old_version and $new_version" echo "* Checking output difference between Gnocchi $old_version and $new_version"
diff -uNr $GNOCCHI_DATA/old $GNOCCHI_DATA/new diff -uNr $GNOCCHI_DATA/old $GNOCCHI_DATA/new