backend.py: Separate out the main work of `_update'.
[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
28import os as OS; ENV = OS.environ
29
30import config as CONF; CFG = CONF.CFG
31import util as U
32
33###--------------------------------------------------------------------------
34### Relevant configuration.
35
36CONF.DEFAULTS.update(
37
38 ## A directory in which we can create lockfiles.
39 LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd'))
40
41###--------------------------------------------------------------------------
42### Protocol.
43###
44### A password backend knows how to fetch and modify records in some password
45### database, e.g., a flat passwd(5)-style password file, or a table in some
46### proper grown-up SQL database.
47###
48### A backend's `lookup' method retrieves the record for a named user from
49### the database, returning it in a record object, or raises `UnknownUser'.
50### The record object maintains `user' (the user name, as supplied to
51### `lookup') and `passwd' (the encrypted password, in whatever form the
52### underlying database uses) attributes, and possibly others. The `passwd'
53### attribute (at least) may be modified by the caller. The record object
54### has a `write' method, which updates the corresponding record in the
55### database.
56###
57### The concrete record objects defined here inherit from `BasicRecord',
58### which keeps track of its parent backend, and implements `write' by
59### calling the backend's `_update' method. Some backends require that their
60### record objects implement additional private protocols.
61
62class UnknownUser (U.ExpectedError):
63 """The named user wasn't found in the database."""
64 def __init__(me, user):
65 U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user)
66 me.user = user
67
68class BasicRecord (object):
69 """
70 A handy base class for record classes.
71
72 Keep track of the backend in `_be', and call its `_update' method to write
73 ourselves back.
74 """
75 def __init__(me, backend):
76 me._be = backend
77 def write(me):
78 me._be._update(me)
79
80class TrivialRecord (BasicRecord):
81 """
82 A trivial record which simply remembers `user' and `passwd' attributes.
83
84 Additional attributes can be set on the object if this is convenient.
85 """
86 def __init__(me, user, passwd, *args, **kw):
87 super(TrivialRecord, me).__init__(*args, **kw)
88 me.user = user
89 me.passwd = passwd
90
91###--------------------------------------------------------------------------
92### Flat files.
93
94class FlatFileRecord (BasicRecord):
95 """
96 A record from a flat-file database (like a passwd(5) file).
97
98 Such a file carries one record per line; each record is split into fields
99 by a delimiter character, specified by the DELIM constructor argument.
100
101 The FMAP argument to the constructor maps names to field index numbers.
102 The standard `user' and `passwd' fields must be included in this map if the
103 object is to implement the protocol correctly (though the `FlatFileBackend'
104 is careful to do this).
105 """
106
107 def __init__(me, line, delim, fmap, *args, **kw):
108 """
109 Initialize the record, splitting the LINE into fields separated by DELIM,
110 and setting attributes under control of FMAP.
111 """
112 super(FlatFileRecord, me).__init__(*args, **kw)
113 line = line.rstrip('\n')
114 fields = line.split(delim)
115 me._delim = delim
116 me._fmap = fmap
117 me._raw = fields
118 for k, v in fmap.iteritems():
119 setattr(me, k, fields[v])
120
121 def _format(me):
122 """
123 Format the record as a line of text.
124
125 The flat-file format is simple, but rather fragile with respect to
126 invalid characters, and often processed by substandard software, so be
127 careful not to allow bad characters into the file.
128 """
129 fields = me._raw
130 for k, v in me._fmap.iteritems():
131 val = getattr(me, k)
132 for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
133 ('\n', 'newline character'),
134 ('\0', 'null character')]:
135 if badch in val:
136 raise U.ExpectedError, \
137 (500, "New `%s' field contains %s" % (k, what))
138 fields[v] = val
1f8350d2 139 return me._delim.join(fields) + '\n'
a2916c06
MW
140
141class FlatFileBackend (object):
142 """
143 Password storage in a flat passwd(5)-style file.
144
145 The FILE constructor argument names the file. Such a file carries one
146 record per line; each record is split into fields by a delimiter character,
147 specified by the DELIM constructor argument.
148
149 The file is updated by writing a new version alongside, as `FILE.new', and
150 renaming it over the old version. If a LOCK file is named then an
151 exclusive fcntl(2)-style lock is taken out on `LOCKDIR/LOCK' (creating the
152 file if necessary) during the update operation. Use of a lockfile is
153 strongly recommended.
154
155 The DELIM constructor argument specifies the delimiter character used when
156 splitting lines into fields. The USER and PASSWD arguments give the field
157 numbers (starting from 0) for the user-name and hashed-password fields;
158 additional field names may be given using keyword arguments: the values of
159 these fields are exposed as attributes `f_NAME' on record objects.
160 """
161
162 def __init__(me, file, lock = None,
163 delim = ':', user = 0, passwd = 1, **fields):
164 """
165 Construct a new flat-file backend object. See the class documentation
166 for details.
167 """
168 me._lock = lock
169 me._file = file
170 me._delim = delim
171 fmap = dict(user = user, passwd = passwd)
172 for k, v in fields.iteritems(): fmap['f_' + k] = v
173 me._fmap = fmap
174
175 def lookup(me, user):
176 """Return the record for the named USER."""
177 with open(me._file) as f:
178 for line in f:
179 rec = me._parse(line)
180 if rec.user == user:
181 return rec
182 raise UnknownUser, user
183
612419ac
MW
184 def _rewrite(me, op, rec):
185 """
186 Rewrite the file, according to OP.
187
188 The OP may be one of the following.
189
190 `create' There must not be a record matching REC; add a new
191 one.
192
193 `remove' There must be a record matching REC: remove it.
194
195 `update' There must be a record matching REC: write REC in its
196 place.
197 """
a2916c06
MW
198
199 ## The main update function.
200 def doit():
201
202 ## Make sure we preserve the file permissions, and in particular don't
203 ## allow a window during which the new file has looser permissions than
204 ## the old one.
205 st = OS.stat(me._file)
206 tmp = me._file + '.new'
207 fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode)
208
209 ## This is the fiddly bit.
210 lose = True
211 try:
212
213 ## Copy the old file to the new one, changing the user's record if
214 ## and when we encounter it.
612419ac 215 found = False
a2916c06
MW
216 with OS.fdopen(fd, 'w') as f_out:
217 with open(me._file) as f_in:
218 for line in f_in:
219 r = me._parse(line)
220 if r.user != rec.user:
221 f_out.write(line)
612419ac
MW
222 elif op == 'create':
223 raise U.ExpectedError, \
224 (500, "Record for `%s' already exists" % rec.user)
a2916c06 225 else:
612419ac
MW
226 found = True
227 if op != 'remove': f_out.write(rec._format())
228 if found:
229 pass
230 elif op == 'create':
231 f_out.write(rec._format())
232 else:
233 raise U.ExpectedError, \
234 (500, "Record for `%s' not found" % rec.user)
a2916c06
MW
235
236 ## Update the permissions on the new file. Don't try to fix the
237 ## ownership (we shouldn't be running as root) or the group (the
238 ## parent directory should have the right permissions already).
239 OS.chmod(tmp, st.st_mode)
240 OS.rename(tmp, me._file)
241 lose = False
242 except OSError, e:
243 ## I suppose that system errors are to be expected at this point.
244 raise U.ExpectedError, \
245 (500, "Failed to update `%s': %s" % (me._file, e))
246 finally:
247 ## Don't try to delete the new file if we succeeded: it might belong
248 ## to another instance of us.
249 if lose:
250 try: OS.unlink(tmp)
251 except: pass
252
74b87214 253 ## If there's a lockfile, then acquire it around the meat of this
a2916c06
MW
254 ## function; otherwise just do the job.
255 if me._lock is None:
256 doit()
257 else:
258 with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5):
259 doit()
260
261 def _parse(me, line):
262 """Convenience function for constructing a record."""
263 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
264
612419ac
MW
265 def _update(me, rec):
266 """Update the record REC in the file."""
267 me._rewrite('update', rec)
268
a2916c06
MW
269CONF.export('FlatFileBackend')
270
271###--------------------------------------------------------------------------
272### SQL databases.
273
274class DatabaseBackend (object):
275 """
276 Password storage in a SQL database table.
277
278 We assume that there's a single table mapping user names to (hashed)
279 passwords: we won't try anything complicated involving joins.
280
281 We need to know a database module MODNAME and arguments MODARGS to pass to
282 the `connect' function. We also need to know the TABLE to search, and the
283 USER and PASSWD field names. Additional field names can be passed to the
284 constructor: these will be read from the database and attached as
285 attributes `f_NAME' to the record returned by `lookup'. Changes to these
286 attributes are currently not propagated back to the database.
287 """
288
289 def __init__(me, modname, modargs, table, user, passwd, *fields):
290 """
291 Create a database backend object. See the class docstring for details.
292 """
293 me._table = table
294 me._user = user
295 me._passwd = passwd
296 me._fields = list(fields)
297
298 ## We don't connect immediately. That would be really bad if we had lots
299 ## of database backends running at a time, because we probably only want
300 ## to use one.
301 me._db = None
302 me._modname = modname
303 me._modargs = modargs
304
305 def _connect(me):
306 """Set up the lazy connection to the database."""
307 if me._db is None:
308 me._db = U.SimpleDBConnection(me._modname, me._modargs)
309
310 def lookup(me, user):
311 """Return the record for the named USER."""
312 me._connect()
313 me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
314 (', '.join([me._passwd] + me._fields),
315 me._table, me._user),
316 user = user)
317 row = me._db.fetchone()
318 if row is None: raise UnknownUser, user
319 passwd = row[0]
320 rec = TrivialRecord(backend = me, user = user, passwd = passwd)
321 for f, v in zip(me._fields, row[1:]):
322 setattr(rec, 'f_' + f, v)
323 return rec
324
325 def _update(me, rec):
326 """Update the record REC in the database."""
327 me._connect()
328 with me._db:
329 me._db.execute(
330 "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
331 me._table, me._passwd, me._user),
332 user = rec.user, passwd = rec.passwd)
333
334CONF.export('DatabaseBackend')
335
336###----- That's all, folks --------------------------------------------------