chiark / gitweb /
operation.py: Refactor `polswitch' a little.
[chopwood] / operation.py
CommitLineData
a2916c06
MW
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
27
28import config as CONF; CFG = CONF.CFG
29import 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
d6b72d90
MW
43###--------------------------------------------------------------------------
44### Some utilities.
45
46OPS = ['set', 'reset', 'clear']
47## A list of the available operations.
48
49class polswitch (U.struct):
50 """A small structure holding a value for each operation."""
51 __slots__ = OPS
52
a2916c06
MW
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
72class 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
106CONF.export('BaseOperation')
107
108class 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)
115CONF.export('SetOperation')
116
117class ClearOperation (BaseOperation):
118 """Operation to clear a password from an account, preventing logins."""
119 def _perform(me):
120 me.svc.clearpasswd(me.user)
121CONF.export('ClearOperation')
122
123class 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
133CONF.export('FailOperation')
134
135###--------------------------------------------------------------------------
136### Requests.
137
138## A request object represents a single user-level operation targetted at
139## multiple services. The user might be known under a different alias by
140## each service, so requests operate on service/user pairs, bundled in an
141## `acct' object.
142##
143## Request methods are as follows.
144##
145## check() Verify that the request complies with policy. Note that
146## checking that any particular user has authority over the
147## necessary accounts has already been done. One might want to
148## check that the passwords are sufficiently long and
149## complicated (though that rapidly becomes problematic, and I
150## don't really recommend it) or that particular services are or
151## aren't processed at the same time.
152##
153## perform() Actually perform the request. A list of completed operation
154## objects is left in the `ops' attribute.
155##
156## Performing the operation may leave additional information in attributes.
157## The `INFO' class attribute contains a dictionary mapping attribute names
158## to human-readable descriptions of this additional information.
159##
160## Note that the request object has a fairly free hand in choosing how to
161## implement the request in terms of operations. In particular, it might
162## process additional services. Callers must not assume that they can
163## predict what the resulting operations list will look like.
164
165class acct (U.struct):
166 """A simple pairing of a service SVC and USER name."""
167 __slots__ = ['svc', 'user']
168
169class BaseRequest (object):
170 """
171 Base class for requests, provides basic protocol. In particular, it
172 provides an empty `INFO' map, a trivial `check' method, and the obvious
173 `perform' method which assumes that the `ops' list has already been
174 constructed.
175 """
176 INFO = {}
177 def check(me):
178 """
179 Check the request to make sure we actually want to proceed.
180 """
181 pass
182 def makeop(me, optype, svc, user, **kw):
183 """
184 Hook for making operations. A policy class can substitute a
185 `FailOperation' to partially disallow a request.
186 """
187 return optype(svc, user, **kw)
188 def perform(me):
189 """
190 Perform the queued-up operations.
191 """
192 for op in me.ops: op.perform()
193 return me.ops
194CONF.export('BaseRequest', ExpectedError = U.ExpectedError)
195
196class SetRequest (BaseRequest):
197 """
198 Request to set the password for the given ACCTS to NEW.
199
200 The new password is kept in the object's `new' attribute for easy
201 inspection. The `check' method ensures that the password is not empty, but
202 imposes no other policy restrictions.
203 """
204 def __init__(me, accts, new):
205 me.new = new
206 me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = new)
207 for acct in accts]
208 def check(me):
209 if me.new == '':
210 raise U.ExpectedError, (400, "Empty password not permitted")
211 super(SetRequest, me).check()
212CONF.export('SetRequest')
213
214class ResetRequest (BaseRequest):
215 """
216 Request to set the password for the given ACCTS to something new but
217 nonspeific. The new password is generated based on a number of class
218 attributes which subclasses can usefully override.
219
220 ENCODING Encoding to apply to random data.
221
222 PWBYTES Number of random bytes to collect.
223
224 Alternatively, subclasses can override the `pwgen' method.
225 """
226
227 ## Password generation parameters.
228 PWBYTES = 16
229 ENCODING = 'base32'
230
231 ## Additional information.
232 INFO = dict(new = 'New password')
233
234 def __init__(me, accts):
235 me.new = me.pwgen()
236 me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = me.new)
237 for acct in accts]
238
239 def pwgen(me):
240 return U.ENCODINGS[me.ENCODING].encode(OS.urandom(me.PWBYTES)) \
241 .rstrip('=')
242CONF.export('ResetRequest')
243
244class ClearRequest (BaseRequest):
245 """
246 Request to clear the password for the given ACCTS.
247 """
248 def __init__(me, accts):
249 me.ops = [me.makeop(ClearOperation, acct.svc, acct.user)
250 for acct in accts]
251CONF.export('ClearRequest')
252
253###--------------------------------------------------------------------------
254### Master policy switch.
255
a2916c06
MW
256CONF.DEFAULTS.update(
257
258 ## Map a request type `set', `reset', or `clear', to the appropriate
259 ## request class.
d6b72d90 260 RQCLASS = polswitch(**dict((i, None) for i in OPS)),
a2916c06
MW
261
262 ## Alternatively, set this to a mixin class to apply common policy to all
263 ## the kinds of requests.
264 RQMIXIN = None)
265
266@CONF.hook
267def set_policy_classes():
268 for op, base in [('set', SetRequest),
269 ('reset', ResetRequest),
270 ('clear', ClearRequest)]:
271 if getattr(CFG.RQCLASS, op): continue
272 if CFG.RQMIXIN:
273 cls = type('Custom%sPolicy' % op.title(), (base, CFG.RQMIXIN), {})
274 else:
275 cls = base
276 setattr(CFG.RQCLASS, op, cls)
277
278## Outcomes.
279
280class outcome (U.struct):
281 __slots__ = ['rc', 'nwin', 'nlose']
282 OK = 0
283 PARTIAL = 1
284 FAIL = 2
285 NOTHING = 3
286
287class info (U.struct):
288 __slots__ = ['desc', 'value']
289
290def operate(op, accts, *args, **kw):
291 """
292 Perform a request through the policy switch.
293
294 The operation may be one of `set', `reset' or `clear'. An instance of the
295 appropriate request class is constructed, and additional arguments are
296 passed directly to the request class constructor; the request is checked
297 for policy compliance; and then performed.
298
299 The return values are:
300
301 * an `outcome' object holding the general outcome, and a count of the
302 winning and losing operations;
303
304 * a list of `info' objects holding additional information from the
305 request;
306
307 * the request object itself; and
308
309 * a list of the individual operation objects.
310 """
311 rq = getattr(CFG.RQCLASS, op)(accts, *args, **kw)
312 rq.check()
313 ops = rq.perform()
314 nwin = nlose = 0
315 for o in ops:
316 if o.error: nlose += 1
317 else: nwin += 1
318 if nwin:
319 if nlose: rc = outcome.PARTIAL
320 else: rc = outcome.OK
321 else:
322 if nlose: rc = outcome.FAIL
323 else: rc = outcome.NOTHING
324 ii = [info(v, getattr(rq, k)) for k, v in rq.INFO.iteritems()]
325 return outcome(rc, nwin, nlose), ii, rq, ops
326
327###----- That's all, folks --------------------------------------------------