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 | |
27 | ||
28 | import config as CONF; CFG = CONF.CFG | |
29 | import 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 | ||
43 | ###-------------------------------------------------------------------------- | |
d6b72d90 MW |
44 | ### Some utilities. |
45 | ||
46 | OPS = ['set', 'reset', 'clear'] | |
47 | ## A list of the available operations. | |
48 | ||
49 | class polswitch (U.struct): | |
50 | """A small structure holding a value for each operation.""" | |
51 | __slots__ = OPS | |
52 | ||
53 | ###-------------------------------------------------------------------------- | |
a2916c06 MW |
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 | ||
72 | class 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 | |
106 | CONF.export('BaseOperation') | |
107 | ||
108 | class 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) | |
115 | CONF.export('SetOperation') | |
116 | ||
117 | class ClearOperation (BaseOperation): | |
118 | """Operation to clear a password from an account, preventing logins.""" | |
119 | def _perform(me): | |
120 | me.svc.clearpasswd(me.user) | |
121 | CONF.export('ClearOperation') | |
122 | ||
123 | class 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 | |
133 | CONF.export('FailOperation') | |
134 | ||
135 | ###-------------------------------------------------------------------------- | |
136 | ### Requests. | |
137 | ||
4e7866ab MW |
138 | CONF.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 | ||
171 | class acct (U.struct): | |
172 | """A simple pairing of a service SVC and USER name.""" | |
173 | __slots__ = ['svc', 'user'] | |
174 | ||
175 | class 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 |
211 | CONF.export('BaseRequest', ExpectedError = U.ExpectedError) |
212 | ||
213 | class 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 |
234 | CONF.export('SetRequest') |
235 | ||
236 | class 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 |
267 | CONF.export('ResetRequest') |
268 | ||
269 | class 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 |
280 | CONF.export('ClearRequest') |
281 | ||
282 | ###-------------------------------------------------------------------------- | |
283 | ### Master policy switch. | |
284 | ||
a2916c06 MW |
285 | CONF.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 | |
296 | def 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 | ||
309 | class outcome (U.struct): | |
310 | __slots__ = ['rc', 'nwin', 'nlose'] | |
311 | OK = 0 | |
312 | PARTIAL = 1 | |
313 | FAIL = 2 | |
314 | NOTHING = 3 | |
315 | ||
316 | class info (U.struct): | |
317 | __slots__ = ['desc', 'value'] | |
318 | ||
319 | def 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 -------------------------------------------------- |