chiark / gitweb /
agpl.py (filez): Check the exit code from the command.
[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
4e7866ab
MW
138CONF.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
a2916c06
MW
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
171class acct (U.struct):
172 """A simple pairing of a service SVC and USER name."""
173 __slots__ = ['svc', 'user']
174
175class BaseRequest (object):
176 """
4e7866ab
MW
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.
a2916c06 183 """
4e7866ab 184
a2916c06 185 INFO = {}
4e7866ab
MW
186 ## A dictionary describing the additional information returned by the
187 ## request: it maps attribute names to human-readable descriptions.
188
a2916c06
MW
189 def check(me):
190 """
191 Check the request to make sure we actually want to proceed.
192 """
4e7866ab
MW
193 if not getattr(CFG.ALLOWOP, me.OP):
194 raise U.ExpectedError, \
195 (401, "Operation `%s' forbidden by policy" % me.OP)
196
a2916c06
MW
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)
4e7866ab 203
a2916c06
MW
204 def perform(me):
205 """
206 Perform the queued-up operations.
207 """
208 for op in me.ops: op.perform()
209 return me.ops
4e7866ab 210
a2916c06
MW
211CONF.export('BaseRequest', ExpectedError = U.ExpectedError)
212
213class 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 """
4e7866ab
MW
221
222 OP = 'set'
223
a2916c06
MW
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]
4e7866ab 228
a2916c06
MW
229 def check(me):
230 if me.new == '':
231 raise U.ExpectedError, (400, "Empty password not permitted")
232 super(SetRequest, me).check()
4e7866ab 233
a2916c06
MW
234CONF.export('SetRequest')
235
236class 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
4e7866ab
MW
249 OP = 'reset'
250
a2916c06
MW
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('=')
4e7866ab 266
a2916c06
MW
267CONF.export('ResetRequest')
268
269class ClearRequest (BaseRequest):
270 """
271 Request to clear the password for the given ACCTS.
272 """
4e7866ab
MW
273
274 OP = 'clear'
275
a2916c06
MW
276 def __init__(me, accts):
277 me.ops = [me.makeop(ClearOperation, acct.svc, acct.user)
278 for acct in accts]
4e7866ab 279
a2916c06
MW
280CONF.export('ClearRequest')
281
282###--------------------------------------------------------------------------
283### Master policy switch.
284
a2916c06
MW
285CONF.DEFAULTS.update(
286
287 ## Map a request type `set', `reset', or `clear', to the appropriate
288 ## request class.
d6b72d90 289 RQCLASS = polswitch(**dict((i, None) for i in OPS)),
a2916c06
MW
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
296def 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
309class outcome (U.struct):
310 __slots__ = ['rc', 'nwin', 'nlose']
311 OK = 0
312 PARTIAL = 1
313 FAIL = 2
314 NOTHING = 3
315
316class info (U.struct):
317 __slots__ = ['desc', 'value']
318
319def 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 --------------------------------------------------