Merge lp:~klmitch/glance/private-images into lp:~hudson-openstack/glance/trunk

Proposed by Kevin L. Mitchell
Status: Merged
Approved by: Jay Pipes
Approved revision: 166
Merged at revision: 158
Proposed branch: lp:~klmitch/glance/private-images
Merge into: lp:~hudson-openstack/glance/trunk
Diff against target: 1052 lines (+510/-54)
17 files modified
Authors (+1/-0)
etc/glance-api.conf (+4/-1)
etc/glance-registry.conf (+7/-1)
glance/api/v1/images.py (+53/-9)
glance/client.py (+4/-2)
glance/common/client.py (+5/-1)
glance/common/context.py (+96/-0)
glance/registry/__init__.py (+17/-16)
glance/registry/client.py (+3/-2)
glance/registry/db/api.py (+16/-2)
glance/registry/db/migrate_repo/versions/007_add_owner.py (+82/-0)
glance/registry/db/models.py (+1/-0)
glance/registry/server.py (+52/-13)
tests/functional/__init__.py (+11/-2)
tests/stubs.py (+5/-2)
tests/unit/test_api.py (+5/-3)
tests/unit/test_context.py (+148/-0)
To merge this branch: bzr merge lp:~klmitch/glance/private-images
Reviewer Review Type Date Requested Status
Jay Pipes (community) Approve
Devin Carlen (community) Approve
Rick Harris (community) Approve
Review via email: mp+68905@code.launchpad.net

Description of the change

Adds authentication middleware support in glance (integration to keystone will be performed as a piece of middleware extending this and committed to the keystone repository). Also implements private images. No limited-visibility shared image support is provided yet.

To post a comment you must log in.
Revision history for this message
Jay Pipes (jaypipes) wrote :

This is excellent work, Kevin. Excellent.

Some tiny nits from me:

156 + # Is this a read-only context?
157 + if req.context.read_only:

That comment is redundant with the if statement IMHO :) There's a few of that comment copy/pasted, too.

323 + """Security context and request information.
324 +
325 + Stores information about the security context under which the user
326 + accesses the system, as well as additional request information.
327 +
328 + """

Please use this docstring style in Glance (sorry, yes this is different from Nova ... but the same as Swift):

"""
Security context and request information.

Stores information about the security context under which the user
accesses the system, as well as additional request information.
"""

There is also no need to put a short description, followed by a longer description for no real reason. In other words, you can just do this:

"""
Object that stores information about the security context under
which the user accesses the system, as well as additional request information.
"""

344 + # No owner == image visible
345 + if image.owner is None:
346 + return True
347 +
348 + # Image is_public == image visible
349 + if image.is_public:
350 + return True

Actually, in order to keep existing behaviour, the above would need to be:

if image.owner is None:
     return image.is_public

If there is no image owner, then the image's visibility should default to its is_public setting in the registry database...

338 + def image_visible(self, image):
339 + """Return True if the image is visible in this context."""

For boolean things, please name the method something like is_image_visible() to make it easier to tell it has a boolean return. Also, you might want to consider making it a @property to be consistent with the owner @property of RequestContext.

385 + req.context = self.make_context(is_admin=True)

Please remove the is_admin property and concept of RequestContext. We can add this functionality later. For now, let's focus on the owner (tenant) and the concept of image ownership, which you've done a great job on.

659 + return db_api.image_get_all(context, **params)

I think you need req.context there, not just context...

723 + except exception.NotAuthorized:
724 + # If it's private and doesn't belong to them, don't let on
725 + # that it exists
726 + raise exc.HTTPNotFound()

That's perfectly fine, and good behaviour, but please add a logger.info() of the unauthorized access so that some admin will be able to tell what actually happened when the inevitable helpdesk call comes in saying "Why doesn't my image exist?! Whhhaaaaa!"

Cheers, and great patch, Kevin! Thank you for taking this task on.
-jay

review: Needs Fixing
Revision history for this message
Jay Pipes (jaypipes) wrote :
Revision history for this message
Rick Harris (rconradharris) wrote :

Rockin' job here Kevin.

Femto-nit:

> self.auth_tok

Would prefer to see this as `auth_token`, better matches what's already in keystone and nova.

review: Approve
Revision history for this message
Devin Carlen (devcamcar) wrote :

Glad to see this! lgtm

review: Approve
Revision history for this message
Kevin L. Mitchell (klmitch) wrote :
Download full text (3.6 KiB)

On Fri, 2011-07-22 at 22:47 +0000, Jay Pipes wrote:
> This is excellent work, Kevin. Excellent.

Thank you :)

> Some tiny nits from me:
>
> 156 + # Is this a read-only context?
> 157 + if req.context.read_only:
>
> That comment is redundant with the if statement IMHO :) There's a few
> of that comment copy/pasted, too.

Yeah, I have a tendency to do that :) I'll remove. Do you see any
other instance of that I should pull out? (I can look, but I might not
even notice...)

> Please use this docstring style in Glance (sorry, yes this is
> different from Nova ... but the same as Swift):

Yeah, that was some of the first code I wrote, so I hadn't gotten a
handle on the docstring style. Ironically, the style you suggest is
pretty much what I do on my own code :)

> 344 + # No owner == image visible
> 345 + if image.owner is None:
> 346 + return True
> 347 +
> 348 + # Image is_public == image visible
> 349 + if image.is_public:
> 350 + return True
>
> Actually, in order to keep existing behaviour, the above would need to be:
>
> if image.owner is None:
> return image.is_public

When you're using the default, non-Keystone-aware ContextMiddleware,
these lines are not even reached, because it sets the is_admin flag on
the context. When you're using a Keystone-aware middleware, the
combined effect of these lines is to allow reading of unowned
is_public=False images, the justification being that providers may wish
to make images available for beta testers without having them show up in
the list to confuse regular users. (Remember that image_visible() is
not used for filtering the list of images, only for checking that users
have permission to read a given image.)

> For boolean things, please name the method something like
> is_image_visible() to make it easier to tell it has a boolean return.
> Also, you might want to consider making it a @property to be
> consistent with the owner @property of RequestContext.

It's not possible to make image_visible() be a property; it has to take
the image as an argument. Renaming it is_image_visible() makes sense,
though.

> 385 + req.context = self.make_context(is_admin=True)
>
> Please remove the is_admin property and concept of RequestContext. We
> can add this functionality later. For now, let's focus on the owner
> (tenant) and the concept of image ownership, which you've done a great
> job on.

I need some kind of context to convey the tenant from the authentication
middleware to its final destination, be it authorization checks or
setting image ownership information. It also gives me an ideal place to
write those authorization checks, i.e.,
image_visible()/is_image_visible(). I'm not sure how I'd do what I've
done without it.

> 659 + return db_api.image_get_all(context, **params)
>
> I think you need req.context there, not just context...

That's in glance.registry.server.Controller._get_images(), which gets
passed the context, not the request.

> 723 + except exception.NotAuthorized:
> 724 + # If it's private and doesn't belong to them, don't let on
> 725 + # that it exists
> 726 + raise exc.HTTPNotFound()
>
> That's perfectly fine, and good behaviour, but please add a
> logger.info() o...

Read more...

Revision history for this message
Kevin L. Mitchell (klmitch) wrote :

> > 385 + req.context = self.make_context(is_admin=True)
> >
> > Please remove the is_admin property and concept of RequestContext. We
> > can add this functionality later. For now, let's focus on the owner
> > (tenant) and the concept of image ownership, which you've done a great
> > job on.
>
> I need some kind of context to convey the tenant from the authentication
> middleware to its final destination, be it authorization checks or
> setting image ownership information. It also gives me an ideal place to
> write those authorization checks, i.e.,
> image_visible()/is_image_visible(). I'm not sure how I'd do what I've
> done without it.

I'm sorry, I just realized I misunderstood you. As it happens, though,
we need to have the is_admin concept there in order to be fully
backwards-compatible: you'll notice that the default ContextMiddleware
sets is_admin to True, and it has to be False for regular users under
the keystone-aware middleware that I've already written or the
authorization checks become meaningless. (In fact, I initially did not
set is_admin to True, and ended up with one failing unit test.)

If you want, we could rename it to something like 'compatibility'--but
the keystone-aware middleware that I've written actually does set it
based on the presence of the 'Admin' role, so it makes more sense to me,
looking at the big picture, to preserve the existing name.
--
Kevin L. Mitchell <email address hidden>

This email may include confidential information. If you received it in error, please delete it.

lp:~klmitch/glance/private-images updated
166. By Kevin L. Mitchell

Take out extraneous comments; tune up doc string; rename image_visible() to is_image_visible(); log authorization failures

Revision history for this message
Jay Pipes (jaypipes) wrote :

> I'm sorry, I just realized I misunderstood you. As it happens, though,
> we need to have the is_admin concept there in order to be fully
> backwards-compatible: you'll notice that the default ContextMiddleware
> sets is_admin to True, and it has to be False for regular users under
> the keystone-aware middleware that I've already written or the
> authorization checks become meaningless. (In fact, I initially did not
> set is_admin to True, and ended up with one failing unit test.)

Right, I understand a little better now.

> If you want, we could rename it to something like 'compatibility'--but
> the keystone-aware middleware that I've written actually does set it
> based on the presence of the 'Admin' role, so it makes more sense to me,
> looking at the big picture, to preserve the existing name.

OK, no worries.

-jay

Revision history for this message
Jay Pipes (jaypipes) wrote :

> > Some tiny nits from me:
> >
> > 156 + # Is this a read-only context?
> > 157 + if req.context.read_only:
> >
> > That comment is redundant with the if statement IMHO :) There's a few
> > of that comment copy/pasted, too.
>
> Yeah, I have a tendency to do that :) I'll remove. Do you see any
> other instance of that I should pull out? (I can look, but I might not
> even notice...)

Yup, just do a search on "read-only" :)

> > 344 + # No owner == image visible
> > 345 + if image.owner is None:
> > 346 + return True
> > 347 +
> > 348 + # Image is_public == image visible
> > 349 + if image.is_public:
> > 350 + return True
> >
> > Actually, in order to keep existing behaviour, the above would need to be:
> >
> > if image.owner is None:
> > return image.is_public
>
> When you're using the default, non-Keystone-aware ContextMiddleware,
> these lines are not even reached, because it sets the is_admin flag on
> the context.

Right, I now understand that after your last comment...

> When you're using a Keystone-aware middleware, the
> combined effect of these lines is to allow reading of unowned
> is_public=False images, the justification being that providers may wish
> to make images available for beta testers without having them show up in
> the list to confuse regular users. (Remember that image_visible() is
> not used for filtering the list of images, only for checking that users
> have permission to read a given image.)

Right, that's the part I was getting confused on... I understand it now.

> > For boolean things, please name the method something like
> > is_image_visible() to make it easier to tell it has a boolean return.
> > Also, you might want to consider making it a @property to be
> > consistent with the owner @property of RequestContext.
>
> It's not possible to make image_visible() be a property; it has to take
> the image as an argument. Renaming it is_image_visible() makes sense,
> though.

Ah, yes, cool.

> > 659 + return db_api.image_get_all(context, **params)
> >
> > I think you need req.context there, not just context...
>
> That's in glance.registry.server.Controller._get_images(), which gets
> passed the context, not the request.

Ah, no prob. Just scanning the code, it looked out of place with the references to req.context around it.

> > 723 + except exception.NotAuthorized:
> > 724 + # If it's private and doesn't belong to them, don't let on
> > 725 + # that it exists
> > 726 + raise exc.HTTPNotFound()
> >
> > That's perfectly fine, and good behaviour, but please add a
> > logger.info() of the unauthorized access so that some admin will be
> > able to tell what actually happened when the inevitable helpdesk call
> > comes in saying "Why doesn't my image exist?! Whhhaaaaa!"
>
> *nod* makes sense. There weren't very many places in this code that had
> logging, which is why I didn't put it in there.

Ya, getting better over time, but a long way to go :)

-jay

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Authors'
2--- Authors 2011-07-02 04:16:33 +0000
3+++ Authors 2011-07-22 23:36:34 +0000
4@@ -12,6 +12,7 @@
5 Josh Kearney <josh@jk0.org>
6 Justin Shepherd <jshepher@rackspace.com>
7 Ken Pepple <ken.pepple@gmail.com>
8+Kevin L. Mitchell <kevin.mitchell@rackspace.com>
9 Matt Dietz <matt.dietz@rackspace.com>
10 Monty Taylor <mordred@inaugust.com>
11 Rick Clark <rick@openstack.org>
12
13=== modified file 'etc/glance-api.conf'
14--- etc/glance-api.conf 2011-05-11 23:03:51 +0000
15+++ etc/glance-api.conf 2011-07-22 23:36:34 +0000
16@@ -52,7 +52,7 @@
17 swift_store_create_container_on_put = False
18
19 [pipeline:glance-api]
20-pipeline = versionnegotiation apiv1app
21+pipeline = versionnegotiation context apiv1app
22
23 [pipeline:versions]
24 pipeline = versionsapp
25@@ -65,3 +65,6 @@
26
27 [filter:versionnegotiation]
28 paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory
29+
30+[filter:context]
31+paste.filter_factory = glance.common.context:filter_factory
32
33=== modified file 'etc/glance-registry.conf'
34--- etc/glance-registry.conf 2011-05-09 18:55:46 +0000
35+++ etc/glance-registry.conf 2011-07-22 23:36:34 +0000
36@@ -29,5 +29,11 @@
37 # before MySQL can drop the connection.
38 sql_idle_timeout = 3600
39
40-[app:glance-registry]
41+[pipeline:glance-registry]
42+pipeline = context registryapp
43+
44+[app:registryapp]
45 paste.app_factory = glance.registry.server:app_factory
46+
47+[filter:context]
48+paste.filter_factory = glance.common.context:filter_factory
49
50=== modified file 'glance/api/v1/images.py'
51--- glance/api/v1/images.py 2011-07-18 18:22:41 +0000
52+++ glance/api/v1/images.py 2011-07-22 23:36:34 +0000
53@@ -27,7 +27,8 @@
54 import webob
55 from webob.exc import (HTTPNotFound,
56 HTTPConflict,
57- HTTPBadRequest)
58+ HTTPBadRequest,
59+ HTTPForbidden)
60
61 from glance.common import exception
62 from glance.common import wsgi
63@@ -96,7 +97,8 @@
64 """
65 params = self._get_query_params(req)
66 try:
67- images = registry.get_images_list(self.options, **params)
68+ images = registry.get_images_list(self.options, req.context,
69+ **params)
70 except exception.Invalid, e:
71 raise HTTPBadRequest(explanation=str(e))
72
73@@ -126,7 +128,8 @@
74 """
75 params = self._get_query_params(req)
76 try:
77- images = registry.get_images_detail(self.options, **params)
78+ images = registry.get_images_detail(self.options, req.context,
79+ **params)
80 except exception.Invalid, e:
81 raise HTTPBadRequest(explanation=str(e))
82 return dict(images=images)
83@@ -226,6 +229,7 @@
84
85 try:
86 image_meta = registry.add_image_metadata(self.options,
87+ req.context,
88 image_meta)
89 return image_meta
90 except exception.Duplicate:
91@@ -238,6 +242,11 @@
92 for line in msg.split('\n'):
93 logger.error(line)
94 raise HTTPBadRequest(msg, request=req, content_type="text/plain")
95+ except exception.NotAuthorized:
96+ msg = "Not authorized to reserve image."
97+ logger.error(msg)
98+ raise HTTPForbidden(msg, request=req,
99+ content_type="text/plain")
100
101 def _upload(self, req, image_meta):
102 """
103@@ -267,7 +276,7 @@
104
105 image_id = image_meta['id']
106 logger.debug("Setting image %s to status 'saving'" % image_id)
107- registry.update_image_metadata(self.options, image_id,
108+ registry.update_image_metadata(self.options, req.context, image_id,
109 {'status': 'saving'})
110 try:
111 logger.debug("Uploading image data for image %(image_id)s "
112@@ -294,7 +303,8 @@
113 logger.debug("Updating image %(image_id)s data. "
114 "Checksum set to %(checksum)s, size set "
115 "to %(size)d" % locals())
116- registry.update_image_metadata(self.options, image_id,
117+ registry.update_image_metadata(self.options, req.context,
118+ image_id,
119 {'checksum': checksum,
120 'size': size})
121
122@@ -306,6 +316,13 @@
123 self._safe_kill(req, image_id)
124 raise HTTPConflict(msg, request=req)
125
126+ except exception.NotAuthorized, e:
127+ msg = ("Unauthorized upload attempt: %s") % str(e)
128+ logger.error(msg)
129+ self._safe_kill(req, image_id)
130+ raise HTTPForbidden(msg, request=req,
131+ content_type='text/plain')
132+
133 except Exception, e:
134 msg = ("Error uploading image: %s") % str(e)
135 logger.error(msg)
136@@ -325,6 +342,7 @@
137 image_meta['location'] = location
138 image_meta['status'] = 'active'
139 return registry.update_image_metadata(self.options,
140+ req.context,
141 image_id,
142 image_meta)
143
144@@ -336,6 +354,7 @@
145 :param image_id: Opaque image identifier
146 """
147 registry.update_image_metadata(self.options,
148+ req.context,
149 image_id,
150 {'status': 'killed'})
151
152@@ -404,6 +423,12 @@
153 and the request body is not application/octet-stream
154 image data.
155 """
156+ if req.context.read_only:
157+ msg = "Read-only access"
158+ logger.debug(msg)
159+ raise HTTPForbidden(msg, request=req,
160+ content_type="text/plain")
161+
162 image_meta = self._reserve(req, image_meta)
163 image_id = image_meta['id']
164
165@@ -425,6 +450,12 @@
166
167 :retval Returns the updated image information as a mapping
168 """
169+ if req.context.read_only:
170+ msg = "Read-only access"
171+ logger.debug(msg)
172+ raise HTTPForbidden(msg, request=req,
173+ content_type="text/plain")
174+
175 orig_image_meta = self.get_image_meta_or_404(req, id)
176 orig_status = orig_image_meta['status']
177
178@@ -432,8 +463,9 @@
179 raise HTTPConflict("Cannot upload to an unqueued image")
180
181 try:
182- image_meta = registry.update_image_metadata(self.options, id,
183- image_meta, True)
184+ image_meta = registry.update_image_metadata(self.options,
185+ req.context, id,
186+ image_meta, True)
187 if image_data is not None:
188 image_meta = self._upload_and_activate(req, image_meta)
189 except exception.Invalid, e:
190@@ -457,6 +489,12 @@
191 :raises HttpNotAuthorized if image or any chunk is not
192 deleteable by the requesting user
193 """
194+ if req.context.read_only:
195+ msg = "Read-only access"
196+ logger.debug(msg)
197+ raise HTTPForbidden(msg, request=req,
198+ content_type="text/plain")
199+
200 image = self.get_image_meta_or_404(req, id)
201
202 # The image's location field may be None in the case
203@@ -471,7 +509,7 @@
204 "Continuing with deletion from registry."
205 logger.error(msg % (image['location'],))
206
207- registry.delete_image_metadata(self.options, id)
208+ registry.delete_image_metadata(self.options, req.context, id)
209
210 def get_image_meta_or_404(self, request, id):
211 """
212@@ -484,12 +522,18 @@
213 :raises HTTPNotFound if image does not exist
214 """
215 try:
216- return registry.get_image_metadata(self.options, id)
217+ return registry.get_image_metadata(self.options,
218+ request.context, id)
219 except exception.NotFound:
220 msg = "Image with identifier %s not found" % id
221 logger.debug(msg)
222 raise HTTPNotFound(msg, request=request,
223 content_type='text/plain')
224+ except exception.NotAuthorized:
225+ msg = "Unauthorized image access"
226+ logger.debug(msg)
227+ raise HTTPForbidden(msg, request=request,
228+ content_type='text/plain')
229
230 def get_store_or_400(self, request, store_name):
231 """
232
233=== modified file 'glance/client.py'
234--- glance/client.py 2011-06-29 15:42:49 +0000
235+++ glance/client.py 2011-07-22 23:36:34 +0000
236@@ -35,7 +35,8 @@
237
238 DEFAULT_PORT = 9292
239
240- def __init__(self, host, port=None, use_ssl=False, doc_root="/v1"):
241+ def __init__(self, host, port=None, use_ssl=False, doc_root="/v1",
242+ auth_tok=None):
243 """
244 Creates a new client to a Glance API service.
245
246@@ -43,10 +44,11 @@
247 :param port: The port where Glance resides (defaults to 9292)
248 :param use_ssl: Should we use HTTPS? (defaults to False)
249 :param doc_root: Prefix for all URLs we request from host
250+ :param auth_tok: The auth token to pass to the server
251 """
252 port = port or self.DEFAULT_PORT
253 self.doc_root = doc_root
254- super(Client, self).__init__(host, port, use_ssl)
255+ super(Client, self).__init__(host, port, use_ssl, auth_tok)
256
257 def do_request(self, method, action, body=None, headers=None, params=None):
258 action = "%s/%s" % (self.doc_root, action.lstrip("/"))
259
260=== modified file 'glance/common/client.py'
261--- glance/common/client.py 2011-06-28 22:01:08 +0000
262+++ glance/common/client.py 2011-07-22 23:36:34 +0000
263@@ -41,17 +41,19 @@
264
265 CHUNKSIZE = 65536
266
267- def __init__(self, host, port, use_ssl):
268+ def __init__(self, host, port, use_ssl, auth_tok):
269 """
270 Creates a new client to some service.
271
272 :param host: The host where service resides
273 :param port: The port where service resides
274 :param use_ssl: Should we use HTTPS?
275+ :param auth_tok: The auth token to pass to the server
276 """
277 self.host = host
278 self.port = port
279 self.use_ssl = use_ssl
280+ self.auth_tok = auth_tok
281 self.connection = None
282
283 def get_connection_type(self):
284@@ -99,6 +101,8 @@
285 try:
286 connection_type = self.get_connection_type()
287 headers = headers or {}
288+ if 'x-auth-token' not in headers and self.auth_tok:
289+ headers['x-auth-token'] = self.auth_tok
290 c = connection_type(self.host, self.port)
291
292 # Do a simple request or a chunked request, depending
293
294=== added file 'glance/common/context.py'
295--- glance/common/context.py 1970-01-01 00:00:00 +0000
296+++ glance/common/context.py 2011-07-22 23:36:34 +0000
297@@ -0,0 +1,96 @@
298+# vim: tabstop=4 shiftwidth=4 softtabstop=4
299+
300+# Copyright 2011 OpenStack LLC.
301+# All Rights Reserved.
302+#
303+# Licensed under the Apache License, Version 2.0 (the "License"); you may
304+# not use this file except in compliance with the License. You may obtain
305+# a copy of the License at
306+#
307+# http://www.apache.org/licenses/LICENSE-2.0
308+#
309+# Unless required by applicable law or agreed to in writing, software
310+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
311+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
312+# License for the specific language governing permissions and limitations
313+# under the License.
314+
315+from glance.common import utils
316+from glance.common import wsgi
317+
318+
319+class RequestContext(object):
320+ """
321+ Stores information about the security context under which the user
322+ accesses the system, as well as additional request information.
323+ """
324+
325+ def __init__(self, auth_tok=None, user=None, tenant=None, is_admin=False,
326+ read_only=False):
327+ self.auth_tok = auth_tok
328+ self.user = user
329+ self.tenant = tenant
330+ self.is_admin = is_admin
331+ self.read_only = read_only
332+
333+ def is_image_visible(self, image):
334+ """Return True if the image is visible in this context."""
335+ # Is admin == image visible
336+ if self.is_admin:
337+ return True
338+
339+ # No owner == image visible
340+ if image.owner is None:
341+ return True
342+
343+ # Image is_public == image visible
344+ if image.is_public:
345+ return True
346+
347+ # Private image
348+ return self.owner is not None and self.owner == image.owner
349+
350+ @property
351+ def owner(self):
352+ """Return the owner to correlate with an image."""
353+ return self.tenant
354+
355+
356+class ContextMiddleware(wsgi.Middleware):
357+ def __init__(self, app, options):
358+ self.options = options
359+ super(ContextMiddleware, self).__init__(app)
360+
361+ def make_context(self, *args, **kwargs):
362+ """
363+ Create a context with the given arguments.
364+ """
365+
366+ # Determine the context class to use
367+ ctxcls = RequestContext
368+ if 'context_class' in self.options:
369+ ctxcls = utils.import_class(self.options['context_class'])
370+
371+ return ctxcls(*args, **kwargs)
372+
373+ def process_request(self, req):
374+ """
375+ Extract any authentication information in the request and
376+ construct an appropriate context from it.
377+ """
378+ # Use the default empty context, with admin turned on for
379+ # backwards compatibility
380+ req.context = self.make_context(is_admin=True)
381+
382+
383+def filter_factory(global_conf, **local_conf):
384+ """
385+ Factory method for paste.deploy
386+ """
387+ conf = global_conf.copy()
388+ conf.update(local_conf)
389+
390+ def filter(app):
391+ return ContextMiddleware(app, conf)
392+
393+ return filter
394
395=== modified file 'glance/registry/__init__.py'
396--- glance/registry/__init__.py 2011-05-16 14:43:33 +0000
397+++ glance/registry/__init__.py 2011-07-22 23:36:34 +0000
398@@ -26,33 +26,33 @@
399 logger = logging.getLogger('glance.registry')
400
401
402-def get_registry_client(options):
403+def get_registry_client(options, cxt):
404 host = options['registry_host']
405 port = int(options['registry_port'])
406- return client.RegistryClient(host, port)
407-
408-
409-def get_images_list(options, **kwargs):
410- c = get_registry_client(options)
411+ return client.RegistryClient(host, port, auth_tok=cxt.auth_tok)
412+
413+
414+def get_images_list(options, context, **kwargs):
415+ c = get_registry_client(options, context)
416 return c.get_images(**kwargs)
417
418
419-def get_images_detail(options, **kwargs):
420- c = get_registry_client(options)
421+def get_images_detail(options, context, **kwargs):
422+ c = get_registry_client(options, context)
423 return c.get_images_detailed(**kwargs)
424
425
426-def get_image_metadata(options, image_id):
427- c = get_registry_client(options)
428+def get_image_metadata(options, context, image_id):
429+ c = get_registry_client(options, context)
430 return c.get_image(image_id)
431
432
433-def add_image_metadata(options, image_meta):
434+def add_image_metadata(options, context, image_meta):
435 if options['debug']:
436 logger.debug("Adding image metadata...")
437 _debug_print_metadata(image_meta)
438
439- c = get_registry_client(options)
440+ c = get_registry_client(options, context)
441 new_image_meta = c.add_image(image_meta)
442
443 if options['debug']:
444@@ -63,12 +63,13 @@
445 return new_image_meta
446
447
448-def update_image_metadata(options, image_id, image_meta, purge_props=False):
449+def update_image_metadata(options, context, image_id, image_meta,
450+ purge_props=False):
451 if options['debug']:
452 logger.debug("Updating image metadata for image %s...", image_id)
453 _debug_print_metadata(image_meta)
454
455- c = get_registry_client(options)
456+ c = get_registry_client(options, context)
457 new_image_meta = c.update_image(image_id, image_meta, purge_props)
458
459 if options['debug']:
460@@ -79,9 +80,9 @@
461 return new_image_meta
462
463
464-def delete_image_metadata(options, image_id):
465+def delete_image_metadata(options, context, image_id):
466 logger.debug("Deleting image metadata for image %s...", image_id)
467- c = get_registry_client(options)
468+ c = get_registry_client(options, context)
469 return c.delete_image(image_id)
470
471
472
473=== modified file 'glance/registry/client.py'
474--- glance/registry/client.py 2011-06-28 14:37:31 +0000
475+++ glance/registry/client.py 2011-07-22 23:36:34 +0000
476@@ -33,16 +33,17 @@
477
478 DEFAULT_PORT = 9191
479
480- def __init__(self, host, port=None, use_ssl=False):
481+ def __init__(self, host, port=None, use_ssl=False, auth_tok=None):
482 """
483 Creates a new client to a Glance Registry service.
484
485 :param host: The host where Glance resides
486 :param port: The port where Glance resides (defaults to 9191)
487 :param use_ssl: Should we use HTTPS? (defaults to False)
488+ :param auth_tok: The auth token to pass to the server
489 """
490 port = port or self.DEFAULT_PORT
491- super(RegistryClient, self).__init__(host, port, use_ssl)
492+ super(RegistryClient, self).__init__(host, port, use_ssl, auth_tok)
493
494 def get_images(self, **kwargs):
495 """
496
497=== modified file 'glance/registry/db/api.py'
498--- glance/registry/db/api.py 2011-07-20 20:52:44 +0000
499+++ glance/registry/db/api.py 2011-07-22 23:36:34 +0000
500@@ -46,7 +46,8 @@
501
502 IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size',
503 'disk_format', 'container_format',
504- 'is_public', 'location', 'checksum'])
505+ 'is_public', 'location', 'checksum',
506+ 'owner'])
507
508 CONTAINER_FORMATS = ['ami', 'ari', 'aki', 'bare', 'ovf']
509 DISK_FORMATS = ['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi',
510@@ -120,7 +121,7 @@
511 """Get an image or raise if it does not exist."""
512 session = session or get_session()
513 try:
514- return session.query(models.Image).\
515+ image = session.query(models.Image).\
516 options(joinedload(models.Image.properties)).\
517 filter_by(deleted=_deleted(context)).\
518 filter_by(id=image_id).\
519@@ -128,6 +129,12 @@
520 except exc.NoResultFound:
521 raise exception.NotFound("No image found with ID %s" % image_id)
522
523+ # Make sure they can look at it
524+ if not context.is_image_visible(image):
525+ raise exception.NotAuthorized("Image not visible to you")
526+
527+ return image
528+
529
530 def image_get_all(context, filters=None, marker=None, limit=None,
531 sort_key='created_at', sort_dir='desc'):
532@@ -168,6 +175,13 @@
533 query = query.filter(models.Image.size <= filters['size_max'])
534 del filters['size_max']
535
536+ if 'is_public' in filters and filters['is_public'] is not None:
537+ the_filter = models.Image.is_public == filters['is_public']
538+ if filters['is_public'] and context.owner is not None:
539+ the_filter = or_(the_filter, models.Image.owner == context.owner)
540+ query = query.filter(the_filter)
541+ del filters['is_public']
542+
543 for (k, v) in filters.pop('properties', {}).items():
544 query = query.filter(models.Image.properties.any(name=k, value=v))
545
546
547=== added file 'glance/registry/db/migrate_repo/versions/007_add_owner.py'
548--- glance/registry/db/migrate_repo/versions/007_add_owner.py 1970-01-01 00:00:00 +0000
549+++ glance/registry/db/migrate_repo/versions/007_add_owner.py 2011-07-22 23:36:34 +0000
550@@ -0,0 +1,82 @@
551+# vim: tabstop=4 shiftwidth=4 softtabstop=4
552+
553+# Copyright 2011 OpenStack LLC.
554+# All Rights Reserved.
555+#
556+# Licensed under the Apache License, Version 2.0 (the "License"); you may
557+# not use this file except in compliance with the License. You may obtain
558+# a copy of the License at
559+#
560+# http://www.apache.org/licenses/LICENSE-2.0
561+#
562+# Unless required by applicable law or agreed to in writing, software
563+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
564+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
565+# License for the specific language governing permissions and limitations
566+# under the License.
567+
568+from migrate.changeset import *
569+from sqlalchemy import *
570+from sqlalchemy.sql import and_, not_
571+
572+from glance.registry.db.migrate_repo.schema import (
573+ Boolean, DateTime, BigInteger, Integer, String,
574+ Text, from_migration_import)
575+
576+
577+def get_images_table(meta):
578+ """
579+ Returns the Table object for the images table that corresponds to
580+ the images table definition of this version.
581+ """
582+ images = Table('images', meta,
583+ Column('id', Integer(), primary_key=True, nullable=False),
584+ Column('name', String(255)),
585+ Column('disk_format', String(20)),
586+ Column('container_format', String(20)),
587+ Column('size', BigInteger()),
588+ Column('status', String(30), nullable=False),
589+ Column('is_public', Boolean(), nullable=False, default=False,
590+ index=True),
591+ Column('location', Text()),
592+ Column('created_at', DateTime(), nullable=False),
593+ Column('updated_at', DateTime()),
594+ Column('deleted_at', DateTime()),
595+ Column('deleted', Boolean(), nullable=False, default=False,
596+ index=True),
597+ Column('checksum', String(32)),
598+ Column('owner', String(255)),
599+ mysql_engine='InnoDB',
600+ useexisting=True)
601+
602+ return images
603+
604+
605+def get_image_properties_table(meta):
606+ """
607+ No changes to the image properties table from 006...
608+ """
609+ (get_image_properties_table,) = from_migration_import(
610+ '006_key_to_name', ['get_image_properties_table'])
611+
612+ image_properties = get_image_properties_table(meta)
613+ return image_properties
614+
615+
616+def upgrade(migrate_engine):
617+ meta = MetaData()
618+ meta.bind = migrate_engine
619+
620+ images = get_images_table(meta)
621+
622+ owner = Column('owner', String(255))
623+ owner.create(images)
624+
625+
626+def downgrade(migrate_engine):
627+ meta = MetaData()
628+ meta.bind = migrate_engine
629+
630+ images = get_images_table(meta)
631+
632+ images.columns['owner'].drop()
633
634=== modified file 'glance/registry/db/models.py'
635--- glance/registry/db/models.py 2011-07-08 15:43:56 +0000
636+++ glance/registry/db/models.py 2011-07-22 23:36:34 +0000
637@@ -105,6 +105,7 @@
638 is_public = Column(Boolean, nullable=False, default=False)
639 location = Column(Text)
640 checksum = Column(String(32))
641+ owner = Column(String(255))
642
643
644 class ImageProperty(BASE, ModelBase):
645
646=== modified file 'glance/registry/server.py'
647--- glance/registry/server.py 2011-07-18 18:22:41 +0000
648+++ glance/registry/server.py 2011-07-22 23:36:34 +0000
649@@ -61,7 +61,7 @@
650 Get images, wrapping in exception if necessary.
651 """
652 try:
653- return db_api.image_get_all(None, **params)
654+ return db_api.image_get_all(context, **params)
655 except exception.NotFound, e:
656 msg = "Invalid marker. Image could not be found."
657 raise exc.HTTPBadRequest(explanation=msg)
658@@ -87,7 +87,7 @@
659 }
660 """
661 params = self._get_query_params(req)
662- images = self._get_images(None, **params)
663+ images = self._get_images(req.context, **params)
664
665 results = []
666 for image in images:
667@@ -111,7 +111,7 @@
668 """
669 params = self._get_query_params(req)
670
671- images = self._get_images(None, **params)
672+ images = self._get_images(req.context, **params)
673 image_dicts = [make_image_dict(i) for i in images]
674 return dict(images=image_dicts)
675
676@@ -146,7 +146,11 @@
677 filters = {}
678 properties = {}
679
680- filters['is_public'] = self._get_is_public(req)
681+ if req.context.is_admin:
682+ # Only admin gets to look for non-public images
683+ filters['is_public'] = self._get_is_public(req)
684+ else:
685+ filters['is_public'] = True
686 for param in req.str_params:
687 if param in SUPPORTED_FILTERS:
688 filters[param] = req.str_params.get(param)
689@@ -223,9 +227,15 @@
690 def show(self, req, id):
691 """Return data about the given image id."""
692 try:
693- image = db_api.image_get(None, id)
694+ image = db_api.image_get(req.context, id)
695 except exception.NotFound:
696 raise exc.HTTPNotFound()
697+ except exception.NotAuthorized:
698+ # If it's private and doesn't belong to them, don't let on
699+ # that it exists
700+ logger.info("Access by %s to image %s denied" %
701+ (req.context.user, id))
702+ raise exc.HTTPNotFound()
703
704 return dict(image=make_image_dict(image))
705
706@@ -238,11 +248,19 @@
707
708 :retval Returns 200 if delete was successful, a fault if not.
709 """
710- context = None
711+ if req.context.read_only:
712+ raise exc.HTTPForbidden()
713+
714 try:
715- db_api.image_destroy(context, id)
716+ db_api.image_destroy(req.context, id)
717 except exception.NotFound:
718 return exc.HTTPNotFound()
719+ except exception.NotAuthorized:
720+ # If it's private and doesn't belong to them, don't let on
721+ # that it exists
722+ logger.info("Access by %s to image %s denied" %
723+ (req.context.user, id))
724+ raise exc.HTTPNotFound()
725
726 def create(self, req, body):
727 """
728@@ -255,14 +273,20 @@
729 which will include the newly-created image's internal id
730 in the 'id' field
731 """
732+ if req.context.read_only:
733+ raise exc.HTTPForbidden()
734+
735 image_data = body['image']
736
737 # Ensure the image has a status set
738 image_data.setdefault('status', 'active')
739
740- context = None
741+ # Set up the image owner
742+ if not req.context.is_admin or 'owner' not in image_data:
743+ image_data['owner'] = req.context.owner
744+
745 try:
746- image_data = db_api.image_create(context, image_data)
747+ image_data = db_api.image_create(req.context, image_data)
748 return dict(image=make_image_dict(image_data))
749 except exception.Duplicate:
750 msg = ("Image with identifier %s already exists!" % id)
751@@ -283,18 +307,25 @@
752
753 :retval Returns the updated image information as a mapping,
754 """
755+ if req.context.read_only:
756+ raise exc.HTTPForbidden()
757+
758 image_data = body['image']
759
760+ # Prohibit modification of 'owner'
761+ if not req.context.is_admin and 'owner' in image_data:
762+ del image_data['owner']
763+
764 purge_props = req.headers.get("X-Glance-Registry-Purge-Props", "false")
765- context = None
766 try:
767 logger.debug("Updating image %(id)s with metadata: %(image_data)r"
768 % locals())
769 if purge_props == "true":
770- updated_image = db_api.image_update(context, id, image_data,
771- True)
772+ updated_image = db_api.image_update(req.context, id,
773+ image_data, True)
774 else:
775- updated_image = db_api.image_update(context, id, image_data)
776+ updated_image = db_api.image_update(req.context, id,
777+ image_data)
778 return dict(image=make_image_dict(updated_image))
779 except exception.Invalid, e:
780 msg = ("Failed to update image metadata. "
781@@ -305,6 +336,14 @@
782 raise exc.HTTPNotFound(body='Image not found',
783 request=req,
784 content_type='text/plain')
785+ except exception.NotAuthorized:
786+ # If it's private and doesn't belong to them, don't let on
787+ # that it exists
788+ logger.info("Access by %s to image %s denied" %
789+ (req.context.user, id))
790+ raise exc.HTTPNotFound(body='Image not found',
791+ request=req,
792+ content_type='text/plain')
793
794
795 def create_resource(options):
796
797=== modified file 'tests/functional/__init__.py'
798--- tests/functional/__init__.py 2011-07-08 15:43:56 +0000
799+++ tests/functional/__init__.py 2011-07-22 23:36:34 +0000
800@@ -149,7 +149,7 @@
801 log_file = %(log_file)s
802
803 [pipeline:glance-api]
804-pipeline = versionnegotiation apiv1app
805+pipeline = versionnegotiation context apiv1app
806
807 [pipeline:versions]
808 pipeline = versionsapp
809@@ -162,6 +162,9 @@
810
811 [filter:versionnegotiation]
812 paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory
813+
814+[filter:context]
815+paste.filter_factory = glance.common.context:filter_factory
816 """
817
818
819@@ -191,8 +194,14 @@
820 sql_connection = %(sql_connection)s
821 sql_idle_timeout = 3600
822
823-[app:glance-registry]
824+[pipeline:glance-registry]
825+pipeline = context registryapp
826+
827+[app:registryapp]
828 paste.app_factory = glance.registry.server:app_factory
829+
830+[filter:context]
831+paste.filter_factory = glance.common.context:filter_factory
832 """
833
834
835
836=== modified file 'tests/stubs.py'
837--- tests/stubs.py 2011-07-20 16:41:38 +0000
838+++ tests/stubs.py 2011-07-22 23:36:34 +0000
839@@ -29,6 +29,7 @@
840 import webob
841
842 import glance.common.client
843+from glance.common import context
844 from glance.common import exception
845 from glance.registry import server as rserver
846 from glance.api import v1 as server
847@@ -172,7 +173,8 @@
848 "sqlite://")
849 options = {'sql_connection': sql_connection, 'verbose': VERBOSE,
850 'debug': DEBUG}
851- res = self.req.get_response(rserver.API(options))
852+ api = context.ContextMiddleware(rserver.API(options), options)
853+ res = self.req.get_response(api)
854
855 # httplib.Response has a read() method...fake it out
856 def fake_reader():
857@@ -223,7 +225,8 @@
858 'registry_port': '9191',
859 'default_store': 'file',
860 'filesystem_store_datadir': FAKE_FILESYSTEM_ROOTDIR}
861- res = self.req.get_response(server.API(options))
862+ api = context.ContextMiddleware(server.API(options), options)
863+ res = self.req.get_response(api)
864
865 # httplib.Response has a read() method...fake it out
866 def fake_reader():
867
868=== modified file 'tests/unit/test_api.py'
869--- tests/unit/test_api.py 2011-07-18 18:22:41 +0000
870+++ tests/unit/test_api.py 2011-07-22 23:36:34 +0000
871@@ -26,6 +26,7 @@
872 import webob
873
874 from glance.api import v1 as server
875+from glance.common import context
876 from glance.registry import server as rserver
877 import glance.registry.db.api
878 from tests import stubs
879@@ -41,8 +42,9 @@
880 stubs.stub_out_registry_and_store_server(self.stubs)
881 stubs.stub_out_registry_db_image_api(self.stubs)
882 stubs.stub_out_filesystem_backend()
883- self.api = rserver.API({'verbose': VERBOSE,
884- 'debug': DEBUG})
885+ options = {'verbose': VERBOSE,
886+ 'debug': DEBUG}
887+ self.api = context.ContextMiddleware(rserver.API(options), options)
888
889 def tearDown(self):
890 """Clear the test environment"""
891@@ -1458,7 +1460,7 @@
892 'sql_connection': sql_connection,
893 'default_store': 'file',
894 'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
895- self.api = server.API(options)
896+ self.api = context.ContextMiddleware(server.API(options), options)
897
898 def tearDown(self):
899 """Clear the test environment"""
900
901=== added file 'tests/unit/test_context.py'
902--- tests/unit/test_context.py 1970-01-01 00:00:00 +0000
903+++ tests/unit/test_context.py 2011-07-22 23:36:34 +0000
904@@ -0,0 +1,148 @@
905+# vim: tabstop=4 shiftwidth=4 softtabstop=4
906+
907+# Copyright 2010-2011 OpenStack, LLC
908+# All Rights Reserved.
909+#
910+# Licensed under the Apache License, Version 2.0 (the "License"); you may
911+# not use this file except in compliance with the License. You may obtain
912+# a copy of the License at
913+#
914+# http://www.apache.org/licenses/LICENSE-2.0
915+#
916+# Unless required by applicable law or agreed to in writing, software
917+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
918+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
919+# License for the specific language governing permissions and limitations
920+# under the License.
921+
922+import unittest
923+
924+from glance.common import context
925+
926+
927+class FakeImage(object):
928+ """
929+ Fake image for providing the image attributes needed for
930+ TestContext.
931+ """
932+
933+ def __init__(self, owner, is_public):
934+ self.owner = owner
935+ self.is_public = is_public
936+
937+
938+class TestContext(unittest.TestCase):
939+ def do_visible(self, exp_res, img_owner, img_public, **kwargs):
940+ """
941+ Perform a context test. Creates a (fake) image with the
942+ specified owner and is_public attributes, then creates a
943+ context with the given keyword arguments and expects exp_res
944+ as the result of an is_image_visible() call on the context.
945+ """
946+
947+ img = FakeImage(img_owner, img_public)
948+ ctx = context.RequestContext(**kwargs)
949+
950+ self.assertEqual(ctx.is_image_visible(img), exp_res)
951+
952+ def test_empty_public(self):
953+ """
954+ Tests that an empty context (with is_admin set to True) can
955+ access an image with is_public set to True.
956+ """
957+ self.do_visible(True, None, True, is_admin=True)
958+
959+ def test_empty_public_owned(self):
960+ """
961+ Tests that an empty context (with is_admin set to True) can
962+ access an owned image with is_public set to True.
963+ """
964+ self.do_visible(True, 'pattieblack', True, is_admin=True)
965+
966+ def test_empty_private(self):
967+ """
968+ Tests that an empty context (with is_admin set to True) can
969+ access an image with is_public set to False.
970+ """
971+ self.do_visible(True, None, False, is_admin=True)
972+
973+ def test_empty_private_owned(self):
974+ """
975+ Tests that an empty context (with is_admin set to True) can
976+ access an owned image with is_public set to False.
977+ """
978+ self.do_visible(True, 'pattieblack', False, is_admin=True)
979+
980+ def test_anon_public(self):
981+ """
982+ Tests that an anonymous context (with is_admin set to False)
983+ can access an image with is_public set to True.
984+ """
985+ self.do_visible(True, None, True)
986+
987+ def test_anon_public_owned(self):
988+ """
989+ Tests that an anonymous context (with is_admin set to False)
990+ can access an owned image with is_public set to True.
991+ """
992+ self.do_visible(True, 'pattieblack', True)
993+
994+ def test_anon_private(self):
995+ """
996+ Tests that an anonymous context (with is_admin set to False)
997+ can access an unowned image with is_public set to False.
998+ """
999+ self.do_visible(True, None, False)
1000+
1001+ def test_anon_private_owned(self):
1002+ """
1003+ Tests that an anonymous context (with is_admin set to False)
1004+ cannot access an owned image with is_public set to False.
1005+ """
1006+ self.do_visible(False, 'pattieblack', False)
1007+
1008+ def test_auth_public(self):
1009+ """
1010+ Tests that an authenticated context (with is_admin set to
1011+ False) can access an image with is_public set to True.
1012+ """
1013+ self.do_visible(True, None, True, tenant='froggy')
1014+
1015+ def test_auth_public_unowned(self):
1016+ """
1017+ Tests that an authenticated context (with is_admin set to
1018+ False) can access an image (which it does not own) with
1019+ is_public set to True.
1020+ """
1021+ self.do_visible(True, 'pattieblack', True, tenant='froggy')
1022+
1023+ def test_auth_public_owned(self):
1024+ """
1025+ Tests that an authenticated context (with is_admin set to
1026+ False) can access an image (which it does own) with is_public
1027+ set to True.
1028+ """
1029+ self.do_visible(True, 'pattieblack', True, tenant='pattieblack')
1030+
1031+ def test_auth_private(self):
1032+ """
1033+ Tests that an authenticated context (with is_admin set to
1034+ False) can access an image with is_public set to False.
1035+ """
1036+ self.do_visible(True, None, False, tenant='froggy')
1037+
1038+ def test_auth_private_unowned(self):
1039+ """
1040+ Tests that an authenticated context (with is_admin set to
1041+ False) cannot access an image (which it does not own) with
1042+ is_public set to False.
1043+ """
1044+ self.do_visible(False, 'pattieblack', False, tenant='froggy')
1045+
1046+ def test_auth_private_owned(self):
1047+ """
1048+ Tests that an authenticated context (with is_admin set to
1049+ False) can access an image (which it does own) with is_public
1050+ set to False.
1051+ """
1052+ self.do_visible(True, 'pattieblack', False, tenant='pattieblack')

Subscribers

People subscribed via source and target branches