chiark / gitweb /
service.py: Incompatible changes to CommandRemoteService.
[chopwood] / operation.py
... / ...
CommitLineData
1### -*-python-*-
2###
3### Operations and policy switch
4###
5### (c) 2013 Mark Wooding
6###
7
8###----- Licensing notice ---------------------------------------------------
9###
10### This file is part of Chopwood: a password-changing service.
11###
12### Chopwood is free software; you can redistribute it and/or modify
13### it under the terms of the GNU Affero General Public License as
14### published by the Free Software Foundation; either version 3 of the
15### License, or (at your option) any later version.
16###
17### Chopwood is distributed in the hope that it will be useful,
18### but WITHOUT ANY WARRANTY; without even the implied warranty of
19### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20### GNU Affero General Public License for more details.
21###
22### You should have received a copy of the GNU Affero General Public
23### License along with Chopwood; if not, see
24### <http://www.gnu.org/licenses/>.
25
26import os as OS
27import syslog as L
28
29import config as CONF; CFG = CONF.CFG
30import util as U
31
32### The objective here is to be able to insert a policy layer between the UI,
33### which is where the user makes requests to change a bunch of accounts, and
34### the backends, which make requested changes without thinking too much
35### about whether they're a good idea.
36###
37### Here, we convert between (nearly) user-level /requests/, which involve
38### doing things to multiple service/user pairs, and /operations/, which
39### represent a single change to be made to a particular service. (This is
40### slightly nontrivial in the case of reset requests, since the intended
41### semantics may be that the services are all assigned the /same/ random
42### password.)
43
44###--------------------------------------------------------------------------
45### Some utilities.
46
47OPS = ['set', 'reset', 'clear']
48## A list of the available operations.
49
50class polswitch (U.struct):
51 """A small structure holding a value for each operation."""
52 __slots__ = OPS
53
54###--------------------------------------------------------------------------
55### Operation protocol.
56
57## An operation deals with a single service/user pair. The protocol works
58## like this. The constructor is essentially passive, storing information
59## about the operation but not actually performing it. The `perform' method
60## attempts to perform the operation, and stores information about the
61## outcome in attributes:
62##
63## error Either `None' or an `ExpectedError' instance indicating what
64## went wrong.
65##
66## result Either `None' or a string providing additional information
67## about the successful completion of the operation.
68##
69## svc The service object on which the operation was attempted.
70##
71## user The user name on which the operation was attempted.
72
73class BaseOperation (object):
74 """
75 Base class for individual operations.
76
77 This is where the basic operation protocol is implemented. Subclasses
78 should store any additional attributes necessary during initialization, and
79 implement a method `_perform' which takes no parameters, performs the
80 operation, and returns any necessary result.
81 """
82
83 def __init__(me, svc, user, *args, **kw):
84 """Initialize the operation, storing the SVC and USER in attributes."""
85 super(BaseOperation, me).__init__(*args, **kw)
86 me.svc = svc
87 me.user = user
88
89 def perform(me):
90 """Perform the operation, and return whether it was successful."""
91
92 ## Set up the `result' and `error' slots here, rather than earlier, to
93 ## catch callers referencing them too early.
94 me.result = me.error = None
95
96 ## Perform the operation, and stash the result.
97 ok = True
98 try:
99 try: me.result = me._perform()
100 except (IOError, OSError), e: raise U.ExpectedError, (500, str(e))
101 except U.ExpectedError, e:
102 me.error = e
103 ok = False
104
105 ## Done.
106 return ok
107CONF.export('BaseOperation')
108
109class SetOperation (BaseOperation):
110 """Operation to set a given password on an account."""
111 def __init__(me, svc, user, passwd, *args, **kw):
112 super(SetOperation, me).__init__(svc, user, *args, **kw)
113 me.passwd = passwd
114 def _perform(me):
115 me.svc.setpasswd(me.user, me.passwd)
116CONF.export('SetOperation')
117
118class ClearOperation (BaseOperation):
119 """Operation to clear a password from an account, preventing logins."""
120 def _perform(me):
121 me.svc.clearpasswd(me.user)
122CONF.export('ClearOperation')
123
124class FailOperation (BaseOperation):
125 """A fake operation which just raises an exception."""
126 def __init__(me, svc, user, exc):
127 me.svc = svc
128 me.user = user
129 me.exc = exc
130 def perform(me):
131 me.result = None
132 me.error = me.exc
133 return False
134CONF.export('FailOperation')
135
136###--------------------------------------------------------------------------
137### Requests.
138
139CONF.DEFAULTS.update(
140
141 ## A boolean switch for each operation to tell us whether it's allowed. By
142 ## default, they all are.
143 ALLOWOP = polswitch(**dict((i, True) for i in OPS)))
144
145## A request object represents a single user-level operation targetted at
146## multiple services. The user might be known under a different alias by
147## each service, so requests operate on service/user pairs, bundled in an
148## `acct' object.
149##
150## Request methods are as follows.
151##
152## check() Verify that the request complies with policy. Note that
153## checking that any particular user has authority over the
154## necessary accounts has already been done. One might want to
155## check that the passwords are sufficiently long and
156## complicated (though that rapidly becomes problematic, and I
157## don't really recommend it) or that particular services are or
158## aren't processed at the same time.
159##
160## perform() Actually perform the request. A list of completed operation
161## objects is left in the `ops' attribute.
162##
163## Performing the operation may leave additional information in attributes.
164## The `INFO' class attribute contains a dictionary mapping attribute names
165## to human-readable descriptions of this additional information.
166##
167## Note that the request object has a fairly free hand in choosing how to
168## implement the request in terms of operations. In particular, it might
169## process additional services. Callers must not assume that they can
170## predict what the resulting operations list will look like.
171
172class acct (U.struct):
173 """A simple pairing of a service SVC and USER name."""
174 __slots__ = ['svc', 'user']
175
176class BaseRequest (object):
177 """
178 Base class for requests, provides basic protocol.
179
180 It provides an empty `INFO' map; a simple `check' method which checks the
181 operation name (in the class attribute `OP') against the configured policy
182 `CFG'ALLOWOP'; and the obvious `perform' method which assumes that the
183 `ops' list has already been constructed.
184 """
185
186 INFO = {}
187 ## A dictionary describing the additional information returned by the
188 ## request: it maps attribute names to human-readable descriptions.
189
190 def check(me):
191 """
192 Check the request to make sure we actually want to proceed.
193 """
194 if not getattr(CFG.ALLOWOP, me.OP):
195 raise U.ExpectedError, \
196 (401, "Operation `%s' forbidden by policy" % me.OP)
197
198 def makeop(me, optype, svc, user, **kw):
199 """
200 Hook for making operations. A policy class can substitute a
201 `FailOperation' to partially disallow a request.
202 """
203 return optype(svc, user, **kw)
204
205 def describe(me):
206 return me.OP
207
208 def perform(me):
209 """
210 Perform the queued-up operations.
211 """
212 for op in me.ops: op.perform()
213 return me.ops
214
215CONF.export('BaseRequest', ExpectedError = U.ExpectedError)
216
217class SetRequest (BaseRequest):
218 """
219 Request to set the password for the given ACCTS to NEW.
220
221 The new password is kept in the object's `new' attribute for easy
222 inspection. The `check' method ensures that the password is not empty, but
223 imposes no other policy restrictions.
224 """
225
226 OP = 'set'
227
228 def __init__(me, accts, new):
229 me.new = new
230 me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = new)
231 for acct in accts]
232
233 def check(me):
234 if me.new == '':
235 raise U.ExpectedError, (400, "Empty password not permitted")
236 super(SetRequest, me).check()
237
238CONF.export('SetRequest')
239
240class ResetRequest (BaseRequest):
241 """
242 Request to set the password for the given ACCTS to something new but
243 nonspeific. The new password is generated based on a number of class
244 attributes which subclasses can usefully override.
245
246 ENCODING Encoding to apply to random data.
247
248 PWBYTES Number of random bytes to collect.
249
250 Alternatively, subclasses can override the `pwgen' method.
251 """
252
253 OP = 'reset'
254
255 ## Password generation parameters.
256 PWBYTES = 16
257 ENCODING = 'base32'
258
259 ## Additional information.
260 INFO = dict(new = 'New password')
261
262 def __init__(me, accts):
263 me.new = me.pwgen()
264 me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = me.new)
265 for acct in accts]
266
267 def pwgen(me):
268 return U.ENCODINGS[me.ENCODING].encode(OS.urandom(me.PWBYTES)) \
269 .rstrip('=')
270
271CONF.export('ResetRequest')
272
273class ClearRequest (BaseRequest):
274 """
275 Request to clear the password for the given ACCTS.
276 """
277
278 OP = 'clear'
279
280 def __init__(me, accts):
281 me.ops = [me.makeop(ClearOperation, acct.svc, acct.user)
282 for acct in accts]
283
284CONF.export('ClearRequest')
285
286###--------------------------------------------------------------------------
287### Master policy switch.
288
289CONF.DEFAULTS.update(
290
291 ## Map a request type `set', `reset', or `clear', to the appropriate
292 ## request class.
293 RQCLASS = polswitch(**dict((i, None) for i in OPS)),
294
295 ## Alternatively, set this to a mixin class to apply common policy to all
296 ## the kinds of requests.
297 RQMIXIN = None)
298
299@CONF.hook
300def set_policy_classes():
301 for op, base in [('set', SetRequest),
302 ('reset', ResetRequest),
303 ('clear', ClearRequest)]:
304 if getattr(CFG.RQCLASS, op): continue
305 if CFG.RQMIXIN:
306 cls = type('Custom%sPolicy' % op.title(), (base, CFG.RQMIXIN), {})
307 else:
308 cls = base
309 setattr(CFG.RQCLASS, op, cls)
310
311## Outcomes.
312
313class outcome (U.struct):
314 __slots__ = ['rc', 'nwin', 'nlose']
315 OK = 0
316 PARTIAL = 1
317 FAIL = 2
318 NOTHING = 3
319
320class info (U.struct):
321 __slots__ = ['desc', 'value']
322
323def operate(op, accts, *args, **kw):
324 """
325 Perform a request through the policy switch.
326
327 The operation may be one of `set', `reset' or `clear'. An instance of the
328 appropriate request class is constructed, and additional arguments are
329 passed directly to the request class constructor; the request is checked
330 for policy compliance; and then performed.
331
332 The return values are:
333
334 * an `outcome' object holding the general outcome, and a count of the
335 winning and losing operations;
336
337 * a list of `info' objects holding additional information from the
338 request;
339
340 * the request object itself; and
341
342 * a list of the individual operation objects.
343 """
344 rq = getattr(CFG.RQCLASS, op)(accts, *args, **kw)
345 desc = rq.describe()
346 try:
347 rq.check()
348 except U.ExpectedError, e:
349 L.syslog('REFUSE %s %s: %s' %
350 (desc,
351 ', '.join(['%s@%s' % (o.user, o.svc.name) for o in rq.ops]),
352 e))
353 raise
354 ops = rq.perform()
355 nwin = nlose = 0
356 for o in ops:
357 if o.error: nlose += 1
358 else: nwin += 1
359 if nwin:
360 if nlose: rc = outcome.PARTIAL
361 else: rc = outcome.OK
362 else:
363 if nlose: rc = outcome.FAIL
364 else: rc = outcome.NOTHING
365 L.syslog('%s %s: %s' % (['OK', 'PARTIAL', 'FAIL', 'NOTHING'][rc],
366 desc,
367 '; '.join(['%s@%s %s' % (o.user, o.svc.name,
368 not o.error and 'OK' or
369 'ERR %s' % o.error)
370 for o in ops])))
371 ii = [info(v, getattr(rq, k)) for k, v in rq.INFO.iteritems()]
372 return outcome(rc, nwin, nlose), ii, rq, ops
373
374###----- That's all, folks --------------------------------------------------