3 ### Operations and policy switch
5 ### (c) 2013 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
29 import config as CONF; CFG = CONF.CFG
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.
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
44 ###--------------------------------------------------------------------------
47 OPS = ['set', 'reset', 'clear']
48 ## A list of the available operations.
50 class polswitch (U.struct):
51 """A small structure holding a value for each operation."""
54 ###--------------------------------------------------------------------------
55 ### Operation protocol.
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:
63 ## error Either `None' or an `ExpectedError' instance indicating what
66 ## result Either `None' or a string providing additional information
67 ## about the successful completion of the operation.
69 ## svc The service object on which the operation was attempted.
71 ## user The user name on which the operation was attempted.
73 class BaseOperation (object):
75 Base class for individual operations.
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.
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)
90 """Perform the operation, and return whether it was successful."""
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
96 ## Perform the operation, and stash the result.
99 try: me.result = me._perform()
100 except (IOError, OSError), e: raise U.ExpectedError, (500, str(e))
101 except U.ExpectedError, e:
107 CONF.export('BaseOperation')
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)
115 me.svc.setpasswd(me.user, me.passwd)
116 CONF.export('SetOperation')
118 class ClearOperation (BaseOperation):
119 """Operation to clear a password from an account, preventing logins."""
121 me.svc.clearpasswd(me.user)
122 CONF.export('ClearOperation')
124 class FailOperation (BaseOperation):
125 """A fake operation which just raises an exception."""
126 def __init__(me, svc, user, exc):
134 CONF.export('FailOperation')
136 ###--------------------------------------------------------------------------
139 CONF.DEFAULTS.update(
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)))
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
150 ## Request methods are as follows.
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.
160 ## perform() Actually perform the request. A list of completed operation
161 ## objects is left in the `ops' attribute.
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.
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.
172 class acct (U.struct):
173 """A simple pairing of a service SVC and USER name."""
174 __slots__ = ['svc', 'user']
176 class BaseRequest (object):
178 Base class for requests, provides basic protocol.
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.
187 ## A dictionary describing the additional information returned by the
188 ## request: it maps attribute names to human-readable descriptions.
192 Check the request to make sure we actually want to proceed.
194 if not getattr(CFG.ALLOWOP, me.OP):
195 raise U.ExpectedError, \
196 (401, "Operation `%s' forbidden by policy" % me.OP)
198 def makeop(me, optype, svc, user, **kw):
200 Hook for making operations. A policy class can substitute a
201 `FailOperation' to partially disallow a request.
203 return optype(svc, user, **kw)
210 Perform the queued-up operations.
212 for op in me.ops: op.perform()
215 CONF.export('BaseRequest', ExpectedError = U.ExpectedError)
217 class SetRequest (BaseRequest):
219 Request to set the password for the given ACCTS to NEW.
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.
228 def __init__(me, accts, new):
230 me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = new)
235 raise U.ExpectedError, (400, "Empty password not permitted")
236 super(SetRequest, me).check()
238 CONF.export('SetRequest')
240 class ResetRequest (BaseRequest):
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.
246 ENCODING Encoding to apply to random data.
248 PWBYTES Number of random bytes to collect.
250 Alternatively, subclasses can override the `pwgen' method.
255 ## Password generation parameters.
259 ## Additional information.
260 INFO = dict(new = 'New password')
262 def __init__(me, accts):
264 me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = me.new)
268 return U.ENCODINGS[me.ENCODING].encode(OS.urandom(me.PWBYTES)) \
271 CONF.export('ResetRequest')
273 class ClearRequest (BaseRequest):
275 Request to clear the password for the given ACCTS.
280 def __init__(me, accts):
281 me.ops = [me.makeop(ClearOperation, acct.svc, acct.user)
284 CONF.export('ClearRequest')
286 ###--------------------------------------------------------------------------
287 ### Master policy switch.
289 CONF.DEFAULTS.update(
291 ## Map a request type `set', `reset', or `clear', to the appropriate
293 RQCLASS = polswitch(**dict((i, None) for i in OPS)),
295 ## Alternatively, set this to a mixin class to apply common policy to all
296 ## the kinds of requests.
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
306 cls = type('Custom%sPolicy' % op.title(), (base, CFG.RQMIXIN), {})
309 setattr(CFG.RQCLASS, op, cls)
313 class outcome (U.struct):
314 __slots__ = ['rc', 'nwin', 'nlose']
320 class info (U.struct):
321 __slots__ = ['desc', 'value']
323 def operate(op, accts, *args, **kw):
325 Perform a request through the policy switch.
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.
332 The return values are:
334 * an `outcome' object holding the general outcome, and a count of the
335 winning and losing operations;
337 * a list of `info' objects holding additional information from the
340 * the request object itself; and
342 * a list of the individual operation objects.
344 rq = getattr(CFG.RQCLASS, op)(accts, *args, **kw)
348 except U.ExpectedError, e:
349 L.syslog('REFUSE %s %s: %s' %
351 ', '.join(['%s@%s' % (o.user, o.svc.name) for o in rq.ops]),
357 if o.error: nlose += 1
360 if nlose: rc = outcome.PARTIAL
361 else: rc = outcome.OK
363 if nlose: rc = outcome.FAIL
364 else: rc = outcome.NOTHING
365 L.syslog('%s %s: %s' % (['OK', 'PARTIAL', 'FAIL', 'NOTHING'][rc],
367 '; '.join(['%s@%s %s' % (o.user, o.svc.name,
368 not o.error and 'OK' or
371 ii = [info(v, getattr(rq, k)) for k, v in rq.INFO.iteritems()]
372 return outcome(rc, nwin, nlose), ii, rq, ops
374 ###----- That's all, folks --------------------------------------------------