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