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