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