chiark / gitweb /
Found in crybaby's working tree.
[chopwood] / backend.py
CommitLineData
a2916c06
MW
1### -*-python-*-
2###
3### Password backends
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
26from __future__ import with_statement
27
e32b221f
MW
28from auto import HOME
29import errno as E
82d4f64b 30import itertools as I
a2916c06
MW
31import os as OS; ENV = OS.environ
32
33import config as CONF; CFG = CONF.CFG
34import util as U
35
36###--------------------------------------------------------------------------
37### Relevant configuration.
38
39CONF.DEFAULTS.update(
40
41 ## A directory in which we can create lockfiles.
d9ca01b9 42 LOCKDIR = OS.path.join(HOME, 'lock'))
a2916c06 43
82d4f64b
MW
44###--------------------------------------------------------------------------
45### Utilities.
46
8f6848e2
MW
47def fill_in_fields(fno_user, fno_passwd, fno_map,
48 user, passwd, args, defaults = None):
82d4f64b
MW
49 """
50 Return a vector of filled-in fields.
51
52 The FNO_... arguments give field numbers: FNO_USER and FNO_PASSWD give the
53 positions for the username and password fields, respectively; and FNO_MAP
54 is a sequence of (NAME, POS) pairs. The USER and PASSWD arguments give the
55 actual user name and password values; ARGS are the remaining arguments,
56 maybe in the form `NAME=VALUE'.
8f6848e2
MW
57
58 If DEFAULTS is given, it is a fully-filled-in sequence of records. The
59 user name and password are taken from the DEFAULTS if USER and PASSWD are
60 `None' on entry; they cannot be set from ARGS.
82d4f64b
MW
61 """
62
63 ## Prepare the result vector, and set up some data structures.
64 n = 2 + len(fno_map)
65 fmap = {}
66 rmap = map(int, xrange(n))
67 ok = True
68 if fno_user >= n or fno_passwd >= n: ok = False
69 for k, i in fno_map:
70 fmap[k] = i
8f6848e2
MW
71 if i in rmap: ok = False
72 else: rmap[i] = "`%s'" % k
82d4f64b
MW
73 if i >= n: ok = False
74 if not ok:
75 raise U.ExpectedError, \
76 (500, "Fields specified aren't contiguous")
77
78 ## Prepare the new record's fields.
79 f = [None]*n
8f6848e2
MW
80 if user is not None: f[fno_user] = user
81 elif defaults is not None: f[no_user] = defaults[no_user]
82 else: raise U.ExpectedError, (500, "No user name given")
83 if passwd is not None: f[fno_passwd] = passwd
84 elif defaults is not None: f[no_passwd] = defaults[no_passwd]
85 else: raise U.ExpectedError, (500, "No password given")
82d4f64b
MW
86
87 for a in args:
88 if '=' in a:
89 k, v = a.split('=', 1)
90 try: i = fmap[k]
91 except KeyError: raise U.ExpectedError, (400, "Unknown field `%s'" % k)
92 else:
93 for i in xrange(n):
94 if f[i] is None: break
95 else:
96 raise U.ExpectedError, (500, "All fields already populated")
97 v = a
98 if f[i] is not None:
99 raise U.ExpectedError, (400, "Field %s is already set" % rmap[i])
100 f[i] = v
101
8f6848e2
MW
102 ## Check that the vector of fields is properly set up. Copy unset values
103 ## from the defaults if they're available.
82d4f64b 104 for i in xrange(n):
8f6848e2
MW
105 if f[i] is not None: pass
106 elif defaults is not None: f[i] = defaults[i]
107 else: raise U.ExpectedError, (500, "Field %s is unset" % rmap[i])
82d4f64b
MW
108
109 ## Done.
110 return f
111
a2916c06
MW
112###--------------------------------------------------------------------------
113### Protocol.
114###
115### A password backend knows how to fetch and modify records in some password
116### database, e.g., a flat passwd(5)-style password file, or a table in some
117### proper grown-up SQL database.
118###
119### A backend's `lookup' method retrieves the record for a named user from
120### the database, returning it in a record object, or raises `UnknownUser'.
121### The record object maintains `user' (the user name, as supplied to
122### `lookup') and `passwd' (the encrypted password, in whatever form the
123### underlying database uses) attributes, and possibly others. The `passwd'
124### attribute (at least) may be modified by the caller. The record object
125### has a `write' method, which updates the corresponding record in the
126### database.
127###
128### The concrete record objects defined here inherit from `BasicRecord',
129### which keeps track of its parent backend, and implements `write' by
130### calling the backend's `_update' method. Some backends require that their
131### record objects implement additional private protocols.
132
133class UnknownUser (U.ExpectedError):
134 """The named user wasn't found in the database."""
135 def __init__(me, user):
136 U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user)
137 me.user = user
138
139class BasicRecord (object):
140 """
141 A handy base class for record classes.
142
143 Keep track of the backend in `_be', and call its `_update' method to write
144 ourselves back.
145 """
146 def __init__(me, backend):
147 me._be = backend
148 def write(me):
149 me._be._update(me)
82d4f64b
MW
150 def remove(me):
151 me._be._remove(me)
a2916c06
MW
152
153class TrivialRecord (BasicRecord):
154 """
155 A trivial record which simply remembers `user' and `passwd' attributes.
156
157 Additional attributes can be set on the object if this is convenient.
158 """
159 def __init__(me, user, passwd, *args, **kw):
160 super(TrivialRecord, me).__init__(*args, **kw)
161 me.user = user
162 me.passwd = passwd
163
164###--------------------------------------------------------------------------
165### Flat files.
166
167class FlatFileRecord (BasicRecord):
168 """
169 A record from a flat-file database (like a passwd(5) file).
170
171 Such a file carries one record per line; each record is split into fields
172 by a delimiter character, specified by the DELIM constructor argument.
173
174 The FMAP argument to the constructor maps names to field index numbers.
175 The standard `user' and `passwd' fields must be included in this map if the
176 object is to implement the protocol correctly (though the `FlatFileBackend'
177 is careful to do this).
178 """
179
8f6848e2 180 def __init__(me, line, delim, fno_user, fno_passwd, fmap, *args, **kw):
a2916c06
MW
181 """
182 Initialize the record, splitting the LINE into fields separated by DELIM,
183 and setting attributes under control of FMAP.
184 """
185 super(FlatFileRecord, me).__init__(*args, **kw)
186 line = line.rstrip('\n')
187 fields = line.split(delim)
188 me._delim = delim
8f6848e2
MW
189 me._fno_user = fno_user
190 me._fno_passwd = fno_passwd
a2916c06
MW
191 me._fmap = fmap
192 me._raw = fields
193 for k, v in fmap.iteritems():
194 setattr(me, k, fields[v])
195
196 def _format(me):
197 """
198 Format the record as a line of text.
199
200 The flat-file format is simple, but rather fragile with respect to
201 invalid characters, and often processed by substandard software, so be
202 careful not to allow bad characters into the file.
203 """
204 fields = me._raw
205 for k, v in me._fmap.iteritems():
206 val = getattr(me, k)
207 for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
208 ('\n', 'newline character'),
209 ('\0', 'null character')]:
210 if badch in val:
211 raise U.ExpectedError, \
212 (500, "New `%s' field contains %s" % (k, what))
213 fields[v] = val
1f8350d2 214 return me._delim.join(fields) + '\n'
a2916c06 215
8f6848e2
MW
216 def edit(me, args):
217 """
218 Modify the record as described by the ARGS.
219
220 Fields other than the user name and password can be modified.
221 """
222 ff = fill_in_fields(
223
a2916c06
MW
224class FlatFileBackend (object):
225 """
226 Password storage in a flat passwd(5)-style file.
227
228 The FILE constructor argument names the file. Such a file carries one
229 record per line; each record is split into fields by a delimiter character,
230 specified by the DELIM constructor argument.
231
232 The file is updated by writing a new version alongside, as `FILE.new', and
e32b221f
MW
233 renaming it over the old version. If a LOCK is provided then this is done
234 while holding a lock. By default, an exclusive fcntl(2)-style lock is
235 taken out on `LOCKDIR/LOCK' (creating the file if necessary) during the
236 update operation, but subclasses can override the `dolocked' method to
237 provide alternative locking behaviour; the LOCK parameter is not
238 interpreted by any other methods. Use of a lockfile is strongly
239 recommended.
a2916c06
MW
240
241 The DELIM constructor argument specifies the delimiter character used when
242 splitting lines into fields. The USER and PASSWD arguments give the field
243 numbers (starting from 0) for the user-name and hashed-password fields;
244 additional field names may be given using keyword arguments: the values of
245 these fields are exposed as attributes `f_NAME' on record objects.
246 """
247
248 def __init__(me, file, lock = None,
249 delim = ':', user = 0, passwd = 1, **fields):
250 """
251 Construct a new flat-file backend object. See the class documentation
252 for details.
253 """
254 me._lock = lock
255 me._file = file
256 me._delim = delim
257 fmap = dict(user = user, passwd = passwd)
258 for k, v in fields.iteritems(): fmap['f_' + k] = v
259 me._fmap = fmap
260
261 def lookup(me, user):
262 """Return the record for the named USER."""
263 with open(me._file) as f:
264 for line in f:
265 rec = me._parse(line)
266 if rec.user == user:
267 return rec
268 raise UnknownUser, user
269
82d4f64b
MW
270 def create(me, user, passwd, args):
271 """
272 Create a new record for the USER.
273
274 The new record has the given PASSWD, and other fields are set from ARGS.
275 Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as
276 set up by the constructor); other ARGS fill in unset fields, left to
277 right.
278 """
279
280 f = fill_in_fields(me._fmap['user'], me._fmap['passwd'],
281 [(k[2:], i)
282 for k, i in me._fmap.iteritems()
283 if k.startswith('f_')],
284 user, passwd, args)
c81f8191 285 r = FlatFileRecord(me._delim.join(f), me._delim, me._fmap, backend = me)
82d4f64b
MW
286 me._rewrite('create', r)
287
612419ac
MW
288 def _rewrite(me, op, rec):
289 """
290 Rewrite the file, according to OP.
291
292 The OP may be one of the following.
293
294 `create' There must not be a record matching REC; add a new
295 one.
296
297 `remove' There must be a record matching REC: remove it.
298
299 `update' There must be a record matching REC: write REC in its
300 place.
301 """
a2916c06
MW
302
303 ## The main update function.
304 def doit():
305
306 ## Make sure we preserve the file permissions, and in particular don't
307 ## allow a window during which the new file has looser permissions than
308 ## the old one.
309 st = OS.stat(me._file)
310 tmp = me._file + '.new'
311 fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode)
312
313 ## This is the fiddly bit.
314 lose = True
315 try:
316
317 ## Copy the old file to the new one, changing the user's record if
318 ## and when we encounter it.
612419ac 319 found = False
a2916c06
MW
320 with OS.fdopen(fd, 'w') as f_out:
321 with open(me._file) as f_in:
322 for line in f_in:
323 r = me._parse(line)
324 if r.user != rec.user:
325 f_out.write(line)
612419ac
MW
326 elif op == 'create':
327 raise U.ExpectedError, \
328 (500, "Record for `%s' already exists" % rec.user)
a2916c06 329 else:
612419ac
MW
330 found = True
331 if op != 'remove': f_out.write(rec._format())
332 if found:
333 pass
334 elif op == 'create':
335 f_out.write(rec._format())
336 else:
337 raise U.ExpectedError, \
338 (500, "Record for `%s' not found" % rec.user)
a2916c06
MW
339
340 ## Update the permissions on the new file. Don't try to fix the
341 ## ownership (we shouldn't be running as root) or the group (the
342 ## parent directory should have the right permissions already).
343 OS.chmod(tmp, st.st_mode)
344 OS.rename(tmp, me._file)
345 lose = False
346 except OSError, e:
347 ## I suppose that system errors are to be expected at this point.
348 raise U.ExpectedError, \
349 (500, "Failed to update `%s': %s" % (me._file, e))
350 finally:
351 ## Don't try to delete the new file if we succeeded: it might belong
352 ## to another instance of us.
353 if lose:
354 try: OS.unlink(tmp)
355 except: pass
356
74b87214 357 ## If there's a lockfile, then acquire it around the meat of this
a2916c06 358 ## function; otherwise just do the job.
e32b221f
MW
359 if me._lock is None: doit()
360 else: me.dolocked(me._lock, doit)
361
362 def dolocked(me, lock, func):
363 """
364 Call FUNC with the LOCK held.
365
366 Subclasses can override this method in order to provide alternative
367 locking functionality.
368 """
369 try: OS.mkdir(CFG.LOCKDIR)
370 except OSError, e:
371 if e.errno != E.EEXIST: raise
372 with U.lockfile(OS.path.join(CFG.LOCKDIR, lock), 5):
373 func()
a2916c06
MW
374
375 def _parse(me, line):
376 """Convenience function for constructing a record."""
377 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
378
612419ac
MW
379 def _update(me, rec):
380 """Update the record REC in the file."""
381 me._rewrite('update', rec)
382
82d4f64b
MW
383 def _remove(me, rec):
384 """Update the record REC in the file."""
385 me._rewrite('remove', rec)
386
a2916c06
MW
387CONF.export('FlatFileBackend')
388
389###--------------------------------------------------------------------------
390### SQL databases.
391
392class DatabaseBackend (object):
393 """
394 Password storage in a SQL database table.
395
396 We assume that there's a single table mapping user names to (hashed)
397 passwords: we won't try anything complicated involving joins.
398
399 We need to know a database module MODNAME and arguments MODARGS to pass to
400 the `connect' function. We also need to know the TABLE to search, and the
401 USER and PASSWD field names. Additional field names can be passed to the
402 constructor: these will be read from the database and attached as
403 attributes `f_NAME' to the record returned by `lookup'. Changes to these
404 attributes are currently not propagated back to the database.
405 """
406
407 def __init__(me, modname, modargs, table, user, passwd, *fields):
408 """
409 Create a database backend object. See the class docstring for details.
410 """
411 me._table = table
412 me._user = user
413 me._passwd = passwd
414 me._fields = list(fields)
415
416 ## We don't connect immediately. That would be really bad if we had lots
417 ## of database backends running at a time, because we probably only want
418 ## to use one.
419 me._db = None
420 me._modname = modname
421 me._modargs = modargs
422
423 def _connect(me):
424 """Set up the lazy connection to the database."""
425 if me._db is None:
426 me._db = U.SimpleDBConnection(me._modname, me._modargs)
427
428 def lookup(me, user):
429 """Return the record for the named USER."""
430 me._connect()
431 me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
432 (', '.join([me._passwd] + me._fields),
433 me._table, me._user),
434 user = user)
435 row = me._db.fetchone()
436 if row is None: raise UnknownUser, user
437 passwd = row[0]
438 rec = TrivialRecord(backend = me, user = user, passwd = passwd)
439 for f, v in zip(me._fields, row[1:]):
440 setattr(rec, 'f_' + f, v)
441 return rec
442
82d4f64b
MW
443 def create(me, user, passwd, args):
444 """
445 Create a new record for the named USER.
446
447 The new record has the given PASSWD, and other fields are set from ARGS.
448 Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as
449 set up by the constructor); other ARGS fill in unset fields, left to
450 right, in the order given to the constructor.
451 """
452
453 tags = ['user', 'passwd'] + \
454 ['t_%d' % 0 for i in xrange(len(me._fields))]
455 f = fill_in_fields(0, 1, list(I.izip(me._fields, I.count(2))),
456 user, passwd, args)
457 me._connect()
458 with me._db:
459 me._db.execute("INSERT INTO %s (%s) VALUES (%s)" %
460 (me._table,
461 ', '.join([me._user, me._passwd] + me._fields),
462 ', '.join(['$%s' % t for t in tags])),
463 **dict(I.izip(tags, f)))
464
465 def _remove(me, rec):
466 """Remove the record REC from the database."""
467 me._connect()
468 with me._db:
469 me._db.execute("DELETE FROM %s WHERE %s = $user" %
470 (me._table, me._user),
471 user = rec.user)
472
a2916c06
MW
473 def _update(me, rec):
474 """Update the record REC in the database."""
475 me._connect()
476 with me._db:
477 me._db.execute(
478 "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
479 me._table, me._passwd, me._user),
480 user = rec.user, passwd = rec.passwd)
481
482CONF.export('DatabaseBackend')
483
484###----- That's all, folks --------------------------------------------------