Merge lp:~hazmat/pyjuju/rapi-login into lp:~hazmat/pyjuju/rapi-rollup

Proposed by Kapil Thangavelu
Status: Merged
Merged at revision: 624
Proposed branch: lp:~hazmat/pyjuju/rapi-login
Merge into: lp:~hazmat/pyjuju/rapi-rollup
Diff against target: 256 lines (+84/-12)
6 files modified
juju/errors.py (+4/-0)
juju/rapi/context.py (+9/-2)
juju/rapi/tests/common.py (+1/-0)
juju/rapi/tests/test_context.py (+3/-1)
juju/rapi/transport/tests/test_ws.py (+47/-2)
juju/rapi/transport/ws.py (+20/-7)
To merge this branch: bzr merge lp:~hazmat/pyjuju/rapi-login
Reviewer Review Type Date Requested Status
Kapil Thangavelu Pending
Review via email: mp+140268@code.launchpad.net

Description of the change

Require login before api usage.

This update requires login before using the ws api and before the delta stream is active.

https://codereview.appspot.com/6935068/

To post a comment you must log in.
Revision history for this message
Kapil Thangavelu (hazmat) wrote :
Download full text (8.5 KiB)

Reviewers: mp+140268_code.launchpad.net,

Message:
Please take a look.

Description:
Add a login api

Login support using admin credentials/secret

https://code.launchpad.net/~hazmat/juju/rapi-login/+merge/140268

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/6935068/

Affected files:
   A [revision details]
   M juju/agents/api.py
   M juju/rapi/context.py
   M juju/rapi/tests/test_context.py
   M juju/state/initialize.py
   M juju/state/tests/test_initialize.py

Index: [revision details]
=== added file '[revision details]'
--- [revision details] 2012-01-01 00:00:00 +0000
+++ [revision details] 2012-01-01 00:00:00 +0000
@@ -0,0 +1,2 @@
+Old revision: <email address hidden>
+New revision: <email address hidden>

Index: juju/agents/api.py
=== modified file 'juju/agents/api.py'
--- juju/agents/api.py 2012-09-10 03:43:29 +0000
+++ juju/agents/api.py 2012-12-12 19:30:20 +0000
@@ -11,6 +11,7 @@
  from juju.errors import JujuError
  from juju.lib.websockets import WebSocketsResource
  from juju.rapi.transport.ws import WebSocketAPIFactory
+from juju.state.auth import make_ace

  from twisted.web.static import Data
  from twisted.web.server import Site
@@ -32,7 +33,7 @@
          log.debug("Received environment data.")
          root = Data("Juju API Server\n", "text/plain")
          self.ws_factory = WebSocketAPIFactory(
- self.config['zookeeper_servers'], self.provider)
+ self.config['zookeeper_servers'], self.provider)
          yield self.ws_factory.startFactory()

          root.putChild('ws', WebSocketsResource(self.ws_factory))
@@ -79,10 +80,30 @@
                  environment = yield watch_d
              returnValue(environment)

+ # Lazy initialize login nodes if not present.
+ yield self._initialize_login()
+
          config = EnvironmentsConfig()
          config.parse(environment_data)
          returnValue(config.get_default())

+ @inlineCallbacks
+ def _initialize_login(self):
+ children = yield self.client.get_children("/")
+ if "login" in children:
+ return
+
+ acls, stat = yield self.client.get_acl("/initialized")
+ admin_acl = None
+
+ for a in acls:
+ if a['id'].startswith('admin'):
+ admin_acl = a
+ break
+
+ yield self.client.create(
+ "/login", acls=[make_ace(admin_acl['id'], all=True)])
+
      def configure(self, options):
          super(APIEndpointAgent, self).configure(options)
          if not options.get("port"):

Index: juju/rapi/context.py
=== modified file 'juju/rapi/context.py'
--- juju/rapi/context.py 2012-10-12 20:49:45 +0000
+++ juju/rapi/context.py 2012-12-12 19:30:20 +0000
@@ -1,5 +1,6 @@
  from StringIO import StringIO
-from twisted.internet.defer import maybeDeferred, succeed
+from twisted.internet.defer import (
+ maybeDeferred, succeed, returnValue, inlineCallbacks)

  from juju.rapi.cmd import add_relation
  from juju.rapi.cmd import add_unit
@@ -22,7 +23,10 @@

  from juju.rapi import rest

+from juju.state.security import Principal
+
  ...

Read more...

Revision history for this message
Nicola Larosa (teknico) wrote :

Code looks good, on the face of it (I'm not familiar with this
codebase).

I dislike one- or two-letter names, even in tests, but that's just
personal preference.

https://codereview.appspot.com/6935068/

Revision history for this message
Benjamin Saller (bcsaller) wrote :

I think I'm a little shy on context here given the comments below. If
the goals are in keeping with the comments then this seems ok, if we are
looking for real ACL access over the tree, it wasn't clear to make that
worked as expected.

https://codereview.appspot.com/6935068/diff/1/juju/agents/api.py
File juju/agents/api.py (right):

https://codereview.appspot.com/6935068/diff/1/juju/agents/api.py#newcode102
juju/agents/api.py:102: break
Its not possible for someone to register another 'admin' like name here
or will the first added always appear first? This seems an odd way of
tracking this. Since this codebase only supports admin anyway wouldn't
this be acls[0] anyway or are there others?

https://codereview.appspot.com/6935068/diff/1/juju/agents/api.py#newcode105
juju/agents/api.py:105: "/login", acls=[make_ace(admin_acl['id'],
all=True)])
I don't recall the details of the security branch, but this copies it
from /initialized to /login basically? I only have vague guesses as to
why this is the process, you need to create it during init to set up the
agents and then add it to the login tree which will in the future
support additional logins?

https://codereview.appspot.com/6935068/diff/1/juju/rapi/context.py
File juju/rapi/context.py (right):

https://codereview.appspot.com/6935068/diff/1/juju/rapi/context.py#newcode222
juju/rapi/context.py:222: yield self.client.get("/login")
This does the ACL check on the login node and and uses that as the
success boolean. Are the other nodes protected with the same ACL? Its
not clear where that's being managed here.

https://codereview.appspot.com/6935068/diff/1/juju/rapi/tests/test_context.py
File juju/rapi/tests/test_context.py (right):

https://codereview.appspot.com/6935068/diff/1/juju/rapi/tests/test_context.py#newcode177
juju/rapi/tests/test_context.py:177:
Again it looks like the ACL check works here, but I'm not sure if the
way this is wired in provides any actual access control outside of that
node. Is that a non-goal with this iteration?

https://codereview.appspot.com/6935068/

Revision history for this message
Kapil Thangavelu (hazmat) wrote :
Download full text (3.5 KiB)

replies to ben's comments.

https://codereview.appspot.com/6935068/diff/1/juju/agents/api.py
File juju/agents/api.py (right):

https://codereview.appspot.com/6935068/diff/1/juju/agents/api.py#newcode102
juju/agents/api.py:102: break
On 2012/12/17 18:36:59, benjamin.saller wrote:
> Its not possible for someone to register another 'admin' like name
here or will
> the first added always appear first? This seems an odd way of tracking
this.
> Since this codebase only supports admin anyway wouldn't this be
acls[0] anyway
> or are there others?

at the moment the only time this node is created is if it doesn't exist,
at the client side. there is no other client that will modify it once
its in place. the initialized node is the marker node for the rest of
the system to go forward once the environment details have been loaded.

ie. while its true that multiple 'admin' users could potentially be
created with different passwords, this node is WORM and thus suitable
for this use.

https://codereview.appspot.com/6935068/diff/1/juju/agents/api.py#newcode105
juju/agents/api.py:105: "/login", acls=[make_ace(admin_acl['id'],
all=True)])
On 2012/12/17 18:36:59, benjamin.saller wrote:
> I don't recall the details of the security branch, but this copies it
from
> /initialized to /login basically?

This copies the admin identity (which is a hash of the secret of the
user id) with a more restrictive acl grant to a newly created login
node. Effectively to read this node you must have authenticated with the
admin credentials.

This code also exists in initialized, but our current usage/deployment
mode is via charm so the initialize code from this branch will never be
called, hence this lazy init technique for the api agent startup.

> I only have vague guesses as to why this is
> the process, you need to create it during init to set up the agents
and then add
> it to the login tree which will in the future support additional
logins?

hopefully the previous comments make it clear.

https://codereview.appspot.com/6935068/diff/1/juju/rapi/context.py
File juju/rapi/context.py (right):

https://codereview.appspot.com/6935068/diff/1/juju/rapi/context.py#newcode222
juju/rapi/context.py:222: yield self.client.get("/login")
On 2012/12/17 18:36:59, benjamin.saller wrote:
> This does the ACL check on the login node and and uses that as the
success
> boolean. Are the other nodes protected with the same ACL? Its not
clear where
> that's being managed here.

access to the node is only possible if the username and password are
correct, ie. match the admin identity used for the node acl.

https://codereview.appspot.com/6935068/diff/1/juju/rapi/tests/test_context.py
File juju/rapi/tests/test_context.py (right):

https://codereview.appspot.com/6935068/diff/1/juju/rapi/tests/test_context.py#newcode177
juju/rapi/tests/test_context.py:177:
On 2012/12/17 18:36:59, benjamin.saller wrote:
> Again it looks like the ACL check works here, but I'm not sure if the
way this
> is wired in provides any actual access control outside of that node.
Is that non-goal with this iteration?

this isn't about access control at the node tree level, that's the
security branch work which is *much* larger ...

Read more...

Revision history for this message
Kapil Thangavelu (hazmat) wrote :
Revision history for this message
Kapil Thangavelu (hazmat) wrote :
Revision history for this message
Benjamin Saller (bcsaller) wrote :

LGTM

I didn't see a test around the login call returning the added
unauthorized error though.

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/context.py
File juju/rapi/context.py (right):

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/context.py#newcode85
juju/rapi/context.py:85: if not self.authenticated and not func.__name__
== "_login":
func.__name__ has a bad smell to it but fair enough

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/transport/ws.py
File juju/rapi/transport/ws.py (right):

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/transport/ws.py#newcode68
juju/rapi/transport/ws.py:68:
Is there an use case for a logout/inverse to this stopping the delta? I
suspect not at this time.

https://codereview.appspot.com/6935068/

Revision history for this message
Kapil Thangavelu (hazmat) wrote :

thanks for the review

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/context.py
File juju/rapi/context.py (right):

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/context.py#newcode85
juju/rapi/context.py:85: if not self.authenticated and not func.__name__
== "_login":
On 2013/01/18 14:04:23, bcsaller wrote:
> func.__name__ has a bad smell to it but fair enough

its already playing with func.. not many other options outside of
decorating every other function with another attribute for perm, which
still amounts to the same, ie func inspection.

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/transport/ws.py
File juju/rapi/transport/ws.py (right):

https://codereview.appspot.com/6935068/diff/12001/juju/rapi/transport/ws.py#newcode68
juju/rapi/transport/ws.py:68:
On 2013/01/18 14:04:23, bcsaller wrote:
> Is there an use case for a logout/inverse to this stopping the delta?
I suspect
> not at this time.

logout not possible with the underlying zk auth mechanism, wrt to use
not at the moment outside of disconnect.

ideally stream sub/unsub would be explicit api methods, but doing so
without gui support first would break the gui.

https://codereview.appspot.com/6935068/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'juju/errors.py'
2--- juju/errors.py 2012-08-28 16:09:47 +0000
3+++ juju/errors.py 2013-01-17 23:19:20 +0000
4@@ -12,6 +12,10 @@
5 """
6
7
8+class Unauthorized(JujuError):
9+ """The user is not authorized to perform the operation."""
10+
11+
12 class IncompatibleVersion(JujuError):
13 """Raised when there is a mismatch in versions using the topology.
14
15
16=== modified file 'juju/rapi/context.py'
17--- juju/rapi/context.py 2012-12-20 04:43:20 +0000
18+++ juju/rapi/context.py 2013-01-17 23:19:20 +0000
19@@ -1,6 +1,8 @@
20 from StringIO import StringIO
21 from twisted.internet.defer import (
22- maybeDeferred, succeed, returnValue, inlineCallbacks)
23+ fail, maybeDeferred, succeed, returnValue, inlineCallbacks)
24+
25+from juju.errors import Unauthorized
26
27 from juju.rapi.cmd import add_relation
28 from juju.rapi.cmd import add_unit
29@@ -67,6 +69,7 @@
30 self.client = client
31 self.provider = provider
32 self.log = LogBuffer()
33+ self.authenticated = False
34
35 def connect(self, *args, **kw):
36 if self.client.connected:
37@@ -80,7 +83,10 @@
38 self.client.close()
39
40 def _invoke(self, func, *args, **kw):
41- d = maybeDeferred(func, *args, **kw)
42+ if not self.authenticated and not func.__name__ == "_login":
43+ d = fail(Unauthorized("Login required."))
44+ else:
45+ d = maybeDeferred(func, *args, **kw)
46 return d.addCallback(
47 lambda r: {'result': r, 'log': self.log.reset()}
48 ).addErrback(self._on_error)
49@@ -233,6 +239,7 @@
50 self.log.error("Invalid credentials")
51 raise
52 else:
53+ self.authenticated = True
54 self.log.info("Login success")
55 returnValue(True)
56
57
58=== modified file 'juju/rapi/tests/common.py'
59--- juju/rapi/tests/common.py 2012-09-27 12:02:51 +0000
60+++ juju/rapi/tests/common.py 2013-01-17 23:19:20 +0000
61@@ -34,6 +34,7 @@
62 "cloudy", {"default-series": "precise",
63 "storage-directory": self.makeDir()})
64 self.context = APIContext(self.client, self.provider)
65+ self.context.authenticated = True
66 self.context.charm_cache_dir = self.makeDir()
67 yield self.client.create("/environment", TEST_ENV)
68
69
70=== modified file 'juju/rapi/tests/test_context.py'
71--- juju/rapi/tests/test_context.py 2012-12-20 04:43:20 +0000
72+++ juju/rapi/tests/test_context.py 2013-01-17 23:19:20 +0000
73@@ -182,12 +182,14 @@
74 principal = Principal(u, p)
75 self.client.create(
76 "/login", acls=[make_ace(principal.get_token(), all=True)])
77+ self.context.authenticated = False
78 result = yield self.context.login(u, "hello")
79 self.assertEqual(result['log'], [('error', 'Invalid credentials')])
80 self.assertEqual(result['err'], True)
81 result = yield self.context.login(u, p)
82 self.assertEqual(result['result'], True)
83 self.assertEqual(result['log'], [('info', 'Login success')])
84+ self.assertTrue(self.context.authenticated)
85 yield self.client.delete("/login")
86
87 @inlineCallbacks
88@@ -205,7 +207,7 @@
89 'public-address': None}},
90 'charm': 'cs:precise/wordpress-3',
91 'relations': {}}},
92- 'machines': {0: {'instance-id': 'pending'}}}
93+ 'machines': {0: {'instance-id': 'pending'}}}
94 self.assertEqual(response['result'], stat)
95
96 response = yield self.context.expose('wordpress')
97
98=== modified file 'juju/rapi/transport/tests/test_ws.py'
99--- juju/rapi/transport/tests/test_ws.py 2012-10-04 15:58:43 +0000
100+++ juju/rapi/transport/tests/test_ws.py 2013-01-17 23:19:20 +0000
101@@ -1,14 +1,16 @@
102 import json
103 import logging
104
105-
106 from twisted.internet.defer import inlineCallbacks, Deferred
107
108 from juju.rapi.transport.ws import WebSocketAPIFactory
109 from juju.providers.dummy import MachineProvider
110
111+from juju.state.auth import make_ace
112+from juju.state.topology import InternalTopology
113+from juju.state.security import Principal
114+
115 from juju.rapi.tests.common import ContextTestBase
116-from juju.state.topology import InternalTopology
117 from juju.state.tests.common import StateTestBase
118 from juju.tests.common import get_test_zookeeper_address
119
120@@ -112,7 +114,10 @@
121 @inlineCallbacks
122 def test_status(self):
123 d = self.transport.watch_kv('op', 'status')
124+
125 self.ws.connectionMade()
126+ self.ws.context.authenticated = True
127+
128 self.ws.dataReceived(json.dumps(dict(
129 op='status')))
130 msg = yield d
131@@ -139,6 +144,44 @@
132 return super(DeltaSocketTest, self).tearDown()
133
134 @inlineCallbacks
135+ def test_delta_after_login(self):
136+ """No delta events are sent till after login.
137+ """
138+ # Setup for login and service deploy
139+ u, p = "admin", "cekret"
140+ self.client.create("/login")
141+ yield self.mock_store_charms(["precise/wordpress"])
142+ self.mocker.replay()
143+
144+ self.ws.connectionMade()
145+ yield self.context.deploy("precise/wordpress")
146+
147+ # Even if we pump the delta stream now, the connected client
148+ # sees nothing till after login.
149+ yield self.stream_manager.pump()
150+
151+ greeting = self.transport.stream[-1]
152+ self.assertEqual(greeting,
153+ {u'state': u'login-required',
154+ u'version': 0,
155+ u'extensions': [],
156+ u'ready': True,
157+ u'default_series': u'precise',
158+ u'provider_type': u'dummy'})
159+
160+ login_d = self.transport.watch_kv('op', 'login')
161+ delta_d = self.transport.watch_kv('op', 'delta')
162+
163+ self.ws.dataReceived(json.dumps(dict(
164+ op='login', user=u, password=p)))
165+
166+ yield login_d
167+ yield delta_d
168+
169+ delta = self.transport.stream[-1]
170+ self.assertEqual(delta['op'], 'delta')
171+
172+ @inlineCallbacks
173 def test_delta_stream(self):
174 yield self.mock_store_charms(["precise/wordpress", "precise/mysql"])
175 self.mocker.replay()
176@@ -146,6 +189,7 @@
177 # There's always an implicit initial pump of current state made
178 # on connection. But no initial state for this test.
179 self.ws.connectionMade()
180+ self.ws.context.authenticated = True
181
182 # Modify state and send events out.
183 yield self.context.deploy("precise/mysql")
184@@ -153,6 +197,7 @@
185 yield self.stream_manager.pump()
186
187 delta = self.transport.stream[-1]
188+
189 self.assertEqual(delta['op'], 'delta')
190 self.assertEqual(delta['result'], [
191 [u'service', u'add', {u'id': 'wordpress',
192
193=== modified file 'juju/rapi/transport/ws.py'
194--- juju/rapi/transport/ws.py 2012-10-04 15:58:43 +0000
195+++ juju/rapi/transport/ws.py 2013-01-17 23:19:20 +0000
196@@ -46,18 +46,26 @@
197 """Process the next message off the websocket channel.
198 """
199 self.transport.write(json.dumps(
200- {"version": 0, "extensions": [], "ready": True,
201+ {"version": 0,
202+ "extensions": [],
203+ "ready": True,
204+ "state": "login-required",
205 "provider_type": self.provider.provider_type,
206 "default_series": self.provider.config.get("default-series")}))
207
208 log.info("Sent greeting")
209
210- # Push the current state.
211- self.publish_changes()
212-
213+ subscribed = False
214 while 1:
215 if not self.active:
216 break
217+
218+ # Enable delta with initial push of current after login.
219+ if self.context.authenticated and not subscribed:
220+ subscribed = True
221+ self.publish_changes()
222+ self.factory.stream_manager.add(self)
223+
224 msg = yield self.queue.get()
225 if msg is None:
226 break
227@@ -123,6 +131,8 @@
228
229 def handle_msg_error(self, err):
230 # TODO: return back a request/op structure with error info.
231+ # ... errors generally handled by context, if we get here the
232+ # connection is hosed.
233 log.warning("Error %s" % err)
234 self.active = False
235 self.transport.write(json.dumps(
236@@ -173,8 +183,12 @@
237 self.zk_hosts = zk_hosts
238 self.provider = provider
239 self.numClients = 0
240- self.stream_manager = DeltaStreamManager(
241- APIContext(ManagedClient(self.zk_hosts), self.provider))
242+
243+ # Setup an internal context soley for use by the stream manager.
244+ stream_context = APIContext(
245+ ManagedClient(self.zk_hosts), self.provider)
246+ stream_context.authenticated = True
247+ self.stream_manager = DeltaStreamManager(stream_context)
248
249 # The common usage for twisted's native ws support is via protocol
250 # wrapper mounted onto an http server. This usage prevents
251@@ -193,5 +207,4 @@
252 def buildProtocol(self, addr=None):
253 p = self.protocol(self.zk_hosts, self.provider)
254 p.factory = self
255- self.stream_manager.add(p)
256 return p

Subscribers

People subscribed via source and target branches

to all changes: