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 | ||
d6b72d90 MW |
43 | ###-------------------------------------------------------------------------- |
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 | ||
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 | ||
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 | ||
138 | ## A request object represents a single user-level operation targetted at | |
139 | ## multiple services. The user might be known under a different alias by | |
140 | ## each service, so requests operate on service/user pairs, bundled in an | |
141 | ## `acct' object. | |
142 | ## | |
143 | ## Request methods are as follows. | |
144 | ## | |
145 | ## check() Verify that the request complies with policy. Note that | |
146 | ## checking that any particular user has authority over the | |
147 | ## necessary accounts has already been done. One might want to | |
148 | ## check that the passwords are sufficiently long and | |
149 | ## complicated (though that rapidly becomes problematic, and I | |
150 | ## don't really recommend it) or that particular services are or | |
151 | ## aren't processed at the same time. | |
152 | ## | |
153 | ## perform() Actually perform the request. A list of completed operation | |
154 | ## objects is left in the `ops' attribute. | |
155 | ## | |
156 | ## Performing the operation may leave additional information in attributes. | |
157 | ## The `INFO' class attribute contains a dictionary mapping attribute names | |
158 | ## to human-readable descriptions of this additional information. | |
159 | ## | |
160 | ## Note that the request object has a fairly free hand in choosing how to | |
161 | ## implement the request in terms of operations. In particular, it might | |
162 | ## process additional services. Callers must not assume that they can | |
163 | ## predict what the resulting operations list will look like. | |
164 | ||
165 | class acct (U.struct): | |
166 | """A simple pairing of a service SVC and USER name.""" | |
167 | __slots__ = ['svc', 'user'] | |
168 | ||
169 | class BaseRequest (object): | |
170 | """ | |
171 | Base class for requests, provides basic protocol. In particular, it | |
172 | provides an empty `INFO' map, a trivial `check' method, and the obvious | |
173 | `perform' method which assumes that the `ops' list has already been | |
174 | constructed. | |
175 | """ | |
176 | INFO = {} | |
177 | def check(me): | |
178 | """ | |
179 | Check the request to make sure we actually want to proceed. | |
180 | """ | |
181 | pass | |
182 | def makeop(me, optype, svc, user, **kw): | |
183 | """ | |
184 | Hook for making operations. A policy class can substitute a | |
185 | `FailOperation' to partially disallow a request. | |
186 | """ | |
187 | return optype(svc, user, **kw) | |
188 | def perform(me): | |
189 | """ | |
190 | Perform the queued-up operations. | |
191 | """ | |
192 | for op in me.ops: op.perform() | |
193 | return me.ops | |
194 | CONF.export('BaseRequest', ExpectedError = U.ExpectedError) | |
195 | ||
196 | class SetRequest (BaseRequest): | |
197 | """ | |
198 | Request to set the password for the given ACCTS to NEW. | |
199 | ||
200 | The new password is kept in the object's `new' attribute for easy | |
201 | inspection. The `check' method ensures that the password is not empty, but | |
202 | imposes no other policy restrictions. | |
203 | """ | |
204 | def __init__(me, accts, new): | |
205 | me.new = new | |
206 | me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = new) | |
207 | for acct in accts] | |
208 | def check(me): | |
209 | if me.new == '': | |
210 | raise U.ExpectedError, (400, "Empty password not permitted") | |
211 | super(SetRequest, me).check() | |
212 | CONF.export('SetRequest') | |
213 | ||
214 | class ResetRequest (BaseRequest): | |
215 | """ | |
216 | Request to set the password for the given ACCTS to something new but | |
217 | nonspeific. The new password is generated based on a number of class | |
218 | attributes which subclasses can usefully override. | |
219 | ||
220 | ENCODING Encoding to apply to random data. | |
221 | ||
222 | PWBYTES Number of random bytes to collect. | |
223 | ||
224 | Alternatively, subclasses can override the `pwgen' method. | |
225 | """ | |
226 | ||
227 | ## Password generation parameters. | |
228 | PWBYTES = 16 | |
229 | ENCODING = 'base32' | |
230 | ||
231 | ## Additional information. | |
232 | INFO = dict(new = 'New password') | |
233 | ||
234 | def __init__(me, accts): | |
235 | me.new = me.pwgen() | |
236 | me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = me.new) | |
237 | for acct in accts] | |
238 | ||
239 | def pwgen(me): | |
240 | return U.ENCODINGS[me.ENCODING].encode(OS.urandom(me.PWBYTES)) \ | |
241 | .rstrip('=') | |
242 | CONF.export('ResetRequest') | |
243 | ||
244 | class ClearRequest (BaseRequest): | |
245 | """ | |
246 | Request to clear the password for the given ACCTS. | |
247 | """ | |
248 | def __init__(me, accts): | |
249 | me.ops = [me.makeop(ClearOperation, acct.svc, acct.user) | |
250 | for acct in accts] | |
251 | CONF.export('ClearRequest') | |
252 | ||
253 | ###-------------------------------------------------------------------------- | |
254 | ### Master policy switch. | |
255 | ||
a2916c06 MW |
256 | CONF.DEFAULTS.update( |
257 | ||
258 | ## Map a request type `set', `reset', or `clear', to the appropriate | |
259 | ## request class. | |
d6b72d90 | 260 | RQCLASS = polswitch(**dict((i, None) for i in OPS)), |
a2916c06 MW |
261 | |
262 | ## Alternatively, set this to a mixin class to apply common policy to all | |
263 | ## the kinds of requests. | |
264 | RQMIXIN = None) | |
265 | ||
266 | @CONF.hook | |
267 | def set_policy_classes(): | |
268 | for op, base in [('set', SetRequest), | |
269 | ('reset', ResetRequest), | |
270 | ('clear', ClearRequest)]: | |
271 | if getattr(CFG.RQCLASS, op): continue | |
272 | if CFG.RQMIXIN: | |
273 | cls = type('Custom%sPolicy' % op.title(), (base, CFG.RQMIXIN), {}) | |
274 | else: | |
275 | cls = base | |
276 | setattr(CFG.RQCLASS, op, cls) | |
277 | ||
278 | ## Outcomes. | |
279 | ||
280 | class outcome (U.struct): | |
281 | __slots__ = ['rc', 'nwin', 'nlose'] | |
282 | OK = 0 | |
283 | PARTIAL = 1 | |
284 | FAIL = 2 | |
285 | NOTHING = 3 | |
286 | ||
287 | class info (U.struct): | |
288 | __slots__ = ['desc', 'value'] | |
289 | ||
290 | def operate(op, accts, *args, **kw): | |
291 | """ | |
292 | Perform a request through the policy switch. | |
293 | ||
294 | The operation may be one of `set', `reset' or `clear'. An instance of the | |
295 | appropriate request class is constructed, and additional arguments are | |
296 | passed directly to the request class constructor; the request is checked | |
297 | for policy compliance; and then performed. | |
298 | ||
299 | The return values are: | |
300 | ||
301 | * an `outcome' object holding the general outcome, and a count of the | |
302 | winning and losing operations; | |
303 | ||
304 | * a list of `info' objects holding additional information from the | |
305 | request; | |
306 | ||
307 | * the request object itself; and | |
308 | ||
309 | * a list of the individual operation objects. | |
310 | """ | |
311 | rq = getattr(CFG.RQCLASS, op)(accts, *args, **kw) | |
312 | rq.check() | |
313 | ops = rq.perform() | |
314 | nwin = nlose = 0 | |
315 | for o in ops: | |
316 | if o.error: nlose += 1 | |
317 | else: nwin += 1 | |
318 | if nwin: | |
319 | if nlose: rc = outcome.PARTIAL | |
320 | else: rc = outcome.OK | |
321 | else: | |
322 | if nlose: rc = outcome.FAIL | |
323 | else: rc = outcome.NOTHING | |
324 | ii = [info(v, getattr(rq, k)) for k, v in rq.INFO.iteritems()] | |
325 | return outcome(rc, nwin, nlose), ii, rq, ops | |
326 | ||
327 | ###----- That's all, folks -------------------------------------------------- |