chiark / gitweb /
service.py: Incompatible changes to CommandRemoteService.
[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
710c89c8 27import syslog as L
a2916c06
MW
28
29import config as CONF; CFG = CONF.CFG
30import 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
d6b72d90
MW
44###--------------------------------------------------------------------------
45### Some utilities.
46
47OPS = ['set', 'reset', 'clear']
48## A list of the available operations.
49
50class polswitch (U.struct):
51 """A small structure holding a value for each operation."""
52 __slots__ = OPS
53
a2916c06
MW
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
73class 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
107CONF.export('BaseOperation')
108
109class 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)
116CONF.export('SetOperation')
117
118class ClearOperation (BaseOperation):
119 """Operation to clear a password from an account, preventing logins."""
120 def _perform(me):
121 me.svc.clearpasswd(me.user)
122CONF.export('ClearOperation')
123
124class FailOperation (BaseOperation):
125 """A fake operation which just raises an exception."""
126 def __init__(me, svc, user, exc):
127 me.svc = svc
ae21e4f3 128 me.user = user
a2916c06
MW
129 me.exc = exc
130 def perform(me):
131 me.result = None
132 me.error = me.exc
133 return False
134CONF.export('FailOperation')
135
136###--------------------------------------------------------------------------
137### Requests.
138
4e7866ab
MW
139CONF.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
a2916c06
MW
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
172class acct (U.struct):
173 """A simple pairing of a service SVC and USER name."""
174 __slots__ = ['svc', 'user']
175
176class BaseRequest (object):
177 """
4e7866ab
MW
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.
a2916c06 184 """
4e7866ab 185
a2916c06 186 INFO = {}
4e7866ab
MW
187 ## A dictionary describing the additional information returned by the
188 ## request: it maps attribute names to human-readable descriptions.
189
a2916c06
MW
190 def check(me):
191 """
192 Check the request to make sure we actually want to proceed.
193 """
4e7866ab
MW
194 if not getattr(CFG.ALLOWOP, me.OP):
195 raise U.ExpectedError, \
196 (401, "Operation `%s' forbidden by policy" % me.OP)
197
a2916c06
MW
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)
4e7866ab 204
710c89c8
MW
205 def describe(me):
206 return me.OP
207
a2916c06
MW
208 def perform(me):
209 """
210 Perform the queued-up operations.
211 """
212 for op in me.ops: op.perform()
213 return me.ops
4e7866ab 214
a2916c06
MW
215CONF.export('BaseRequest', ExpectedError = U.ExpectedError)
216
217class 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 """
4e7866ab
MW
225
226 OP = 'set'
227
a2916c06
MW
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]
4e7866ab 232
a2916c06
MW
233 def check(me):
234 if me.new == '':
235 raise U.ExpectedError, (400, "Empty password not permitted")
236 super(SetRequest, me).check()
4e7866ab 237
a2916c06
MW
238CONF.export('SetRequest')
239
240class 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
4e7866ab
MW
253 OP = 'reset'
254
a2916c06
MW
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('=')
4e7866ab 270
a2916c06
MW
271CONF.export('ResetRequest')
272
273class ClearRequest (BaseRequest):
274 """
275 Request to clear the password for the given ACCTS.
276 """
4e7866ab
MW
277
278 OP = 'clear'
279
a2916c06
MW
280 def __init__(me, accts):
281 me.ops = [me.makeop(ClearOperation, acct.svc, acct.user)
282 for acct in accts]
4e7866ab 283
a2916c06
MW
284CONF.export('ClearRequest')
285
286###--------------------------------------------------------------------------
287### Master policy switch.
288
a2916c06
MW
289CONF.DEFAULTS.update(
290
291 ## Map a request type `set', `reset', or `clear', to the appropriate
292 ## request class.
d6b72d90 293 RQCLASS = polswitch(**dict((i, None) for i in OPS)),
a2916c06
MW
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
300def 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
313class outcome (U.struct):
314 __slots__ = ['rc', 'nwin', 'nlose']
315 OK = 0
316 PARTIAL = 1
317 FAIL = 2
318 NOTHING = 3
319
320class info (U.struct):
321 __slots__ = ['desc', 'value']
322
323def 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)
710c89c8
MW
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
a2916c06
MW
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
710c89c8
MW
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])))
a2916c06
MW
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 --------------------------------------------------