Commit | Line | Data |
---|---|---|
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 | ||
26 | import os as OS | |
710c89c8 | 27 | import syslog as L |
a2916c06 MW |
28 | |
29 | import config as CONF; CFG = CONF.CFG | |
30 | import 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 | ||
44 | ###-------------------------------------------------------------------------- | |
d6b72d90 MW |
45 | ### Some utilities. |
46 | ||
47 | OPS = ['set', 'reset', 'clear'] | |
48 | ## A list of the available operations. | |
49 | ||
50 | class polswitch (U.struct): | |
51 | """A small structure holding a value for each operation.""" | |
52 | __slots__ = OPS | |
53 | ||
54 | ###-------------------------------------------------------------------------- | |
a2916c06 MW |
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 | ||
73 | class 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 | |
107 | CONF.export('BaseOperation') | |
108 | ||
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) | |
113 | me.passwd = passwd | |
114 | def _perform(me): | |
115 | me.svc.setpasswd(me.user, me.passwd) | |
116 | CONF.export('SetOperation') | |
117 | ||
118 | class ClearOperation (BaseOperation): | |
119 | """Operation to clear a password from an account, preventing logins.""" | |
120 | def _perform(me): | |
121 | me.svc.clearpasswd(me.user) | |
122 | CONF.export('ClearOperation') | |
123 | ||
124 | class 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 | |
134 | CONF.export('FailOperation') | |
135 | ||
136 | ###-------------------------------------------------------------------------- | |
137 | ### Requests. | |
138 | ||
4e7866ab MW |
139 | CONF.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 | ||
172 | class acct (U.struct): | |
173 | """A simple pairing of a service SVC and USER name.""" | |
174 | __slots__ = ['svc', 'user'] | |
175 | ||
176 | class 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 | |
5d1f4e27 | 182 | `CFG.ALLOWOP'; and the obvious `perform' method which assumes that the |
4e7866ab | 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 |
215 | CONF.export('BaseRequest', ExpectedError = U.ExpectedError) |
216 | ||
217 | class 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 |
238 | CONF.export('SetRequest') |
239 | ||
240 | class 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 |
271 | CONF.export('ResetRequest') |
272 | ||
273 | class 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 |
284 | CONF.export('ClearRequest') |
285 | ||
286 | ###-------------------------------------------------------------------------- | |
287 | ### Master policy switch. | |
288 | ||
a2916c06 MW |
289 | CONF.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 | |
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 | |
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 | ||
313 | class outcome (U.struct): | |
314 | __slots__ = ['rc', 'nwin', 'nlose'] | |
315 | OK = 0 | |
316 | PARTIAL = 1 | |
317 | FAIL = 2 | |
318 | NOTHING = 3 | |
319 | ||
320 | class info (U.struct): | |
321 | __slots__ = ['desc', 'value'] | |
322 | ||
323 | def 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 -------------------------------------------------- |