chiark / gitweb /
backend.py: Make FlatFileRecord._format include the trailing newline.
[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 import syslog as L
28
29 import config as CONF; CFG = CONF.CFG
30 import 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
47 OPS = ['set', 'reset', 'clear']
48 ## A list of the available operations.
49
50 class 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
73 class 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
107 CONF.export('BaseOperation')
108
109 class 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)
116 CONF.export('SetOperation')
117
118 class ClearOperation (BaseOperation):
119   """Operation to clear a password from an account, preventing logins."""
120   def _perform(me):
121     me.svc.clearpasswd(me.user)
122 CONF.export('ClearOperation')
123
124 class 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
134 CONF.export('FailOperation')
135
136 ###--------------------------------------------------------------------------
137 ### Requests.
138
139 CONF.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
172 class acct (U.struct):
173   """A simple pairing of a service SVC and USER name."""
174   __slots__ = ['svc', 'user']
175
176 class 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
215 CONF.export('BaseRequest', ExpectedError = U.ExpectedError)
216
217 class 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
238 CONF.export('SetRequest')
239
240 class 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
271 CONF.export('ResetRequest')
272
273 class 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
284 CONF.export('ClearRequest')
285
286 ###--------------------------------------------------------------------------
287 ### Master policy switch.
288
289 CONF.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
300 def 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
313 class outcome (U.struct):
314   __slots__ = ['rc', 'nwin', 'nlose']
315   OK = 0
316   PARTIAL = 1
317   FAIL = 2
318   NOTHING = 3
319
320 class info (U.struct):
321   __slots__ = ['desc', 'value']
322
323 def 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 --------------------------------------------------