From 82d4f64b774253438506cec05eacad5af158ccac Mon Sep 17 00:00:00 2001 Message-Id: <82d4f64b774253438506cec05eacad5af158ccac.1715601671.git.mdw@distorted.org.uk> From: Mark Wooding Date: Sat, 24 May 2014 14:00:03 +0100 Subject: [PATCH] Automatically add and remove password database records. Organization: Straylight/Edgeware From: Mark Wooding Unless the service explicitly disables this, the `addacct' command now creates a record in the appropriate database, and `delacct' removes it again. This involves a chunk of additional service protocol, and new remote commands. Also, deleting a user now involves explicitly removing the associated records. --- backend.py | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd-admin.py | 40 ++++++++++++++++-- cmd-remote.py | 16 ++++++++ service.py | 46 ++++++++++++++++++--- 4 files changed, 203 insertions(+), 10 deletions(-) diff --git a/backend.py b/backend.py index 54c5374..7c6645a 100644 --- a/backend.py +++ b/backend.py @@ -25,6 +25,7 @@ from __future__ import with_statement +import itertools as I import os as OS; ENV = OS.environ import config as CONF; CFG = CONF.CFG @@ -38,6 +39,62 @@ CONF.DEFAULTS.update( ## A directory in which we can create lockfiles. LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd')) +###-------------------------------------------------------------------------- +### Utilities. + +def fill_in_fields(fno_user, fno_passwd, fno_map, user, passwd, args): + """ + Return a vector of filled-in fields. + + The FNO_... arguments give field numbers: FNO_USER and FNO_PASSWD give the + positions for the username and password fields, respectively; and FNO_MAP + is a sequence of (NAME, POS) pairs. The USER and PASSWD arguments give the + actual user name and password values; ARGS are the remaining arguments, + maybe in the form `NAME=VALUE'. + """ + + ## Prepare the result vector, and set up some data structures. + n = 2 + len(fno_map) + fmap = {} + rmap = map(int, xrange(n)) + ok = True + if fno_user >= n or fno_passwd >= n: ok = False + for k, i in fno_map: + fmap[k] = i + rmap[i] = "`%s'" % k + if i >= n: ok = False + if not ok: + raise U.ExpectedError, \ + (500, "Fields specified aren't contiguous") + + ## Prepare the new record's fields. + f = [None]*n + f[fno_user] = user + f[fno_passwd] = passwd + + for a in args: + if '=' in a: + k, v = a.split('=', 1) + try: i = fmap[k] + except KeyError: raise U.ExpectedError, (400, "Unknown field `%s'" % k) + else: + for i in xrange(n): + if f[i] is None: break + else: + raise U.ExpectedError, (500, "All fields already populated") + v = a + if f[i] is not None: + raise U.ExpectedError, (400, "Field %s is already set" % rmap[i]) + f[i] = v + + ## Check that the vector of fields is properly set up. + for i in xrange(n): + if f[i] is None: + raise U.ExpectedError, (500, "Field %s is unset" % rmap[i]) + + ## Done. + return f + ###-------------------------------------------------------------------------- ### Protocol. ### @@ -76,6 +133,8 @@ class BasicRecord (object): me._be = backend def write(me): me._be._update(me) + def remove(me): + me._be._remove(me) class TrivialRecord (BasicRecord): """ @@ -181,6 +240,24 @@ class FlatFileBackend (object): return rec raise UnknownUser, user + def create(me, user, passwd, args): + """ + Create a new record for the USER. + + The new record has the given PASSWD, and other fields are set from ARGS. + Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as + set up by the constructor); other ARGS fill in unset fields, left to + right. + """ + + f = fill_in_fields(me._fmap['user'], me._fmap['passwd'], + [(k[2:], i) + for k, i in me._fmap.iteritems() + if k.startswith('f_')], + user, passwd, args) + r = FlatFileRecord(':'.join(f), me._delim, me._fmap, backend = me) + me._rewrite('create', r) + def _rewrite(me, op, rec): """ Rewrite the file, according to OP. @@ -266,6 +343,10 @@ class FlatFileBackend (object): """Update the record REC in the file.""" me._rewrite('update', rec) + def _remove(me, rec): + """Update the record REC in the file.""" + me._rewrite('remove', rec) + CONF.export('FlatFileBackend') ###-------------------------------------------------------------------------- @@ -322,6 +403,36 @@ class DatabaseBackend (object): setattr(rec, 'f_' + f, v) return rec + def create(me, user, passwd, args): + """ + Create a new record for the named USER. + + The new record has the given PASSWD, and other fields are set from ARGS. + Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as + set up by the constructor); other ARGS fill in unset fields, left to + right, in the order given to the constructor. + """ + + tags = ['user', 'passwd'] + \ + ['t_%d' % 0 for i in xrange(len(me._fields))] + f = fill_in_fields(0, 1, list(I.izip(me._fields, I.count(2))), + user, passwd, args) + me._connect() + with me._db: + me._db.execute("INSERT INTO %s (%s) VALUES (%s)" % + (me._table, + ', '.join([me._user, me._passwd] + me._fields), + ', '.join(['$%s' % t for t in tags])), + **dict(I.izip(tags, f))) + + def _remove(me, rec): + """Remove the record REC from the database.""" + me._connect() + with me._db: + me._db.execute("DELETE FROM %s WHERE %s = $user" % + (me._table, me._user), + user = rec.user) + def _update(me, rec): """Update the record REC in the database.""" me._connect() diff --git a/cmd-admin.py b/cmd-admin.py index 577fe7a..fb72497 100644 --- a/cmd-admin.py +++ b/cmd-admin.py @@ -26,9 +26,13 @@ from __future__ import with_statement import agpl as AGPL +import backend as BE import cmdutil as CU +import config as CONF; CFG = CONF.CFG import dbmaint as D +import operation as OP from output import OUT, PRINT +import service as S import subcommand as SC import util as U @@ -64,6 +68,19 @@ def cmd_adduser(user, email = None): def cmd_deluser(user, email = None): with D.DB: CU.check_user(user) + for service, alias in D.DB.execute( + "SELECT service, alias FROM services WHERE user = $user", + user = user): + if service == 'master': continue + try: + svc = S.SERVICES[service] + except KeyError: + OUT.warn("User `%s' has account for unknown service `%s'" % + (user, service)) + else: + if svc.manage_pwent_p: + if alias is None: alias = user + svc.rmpwent(alias) D.DB.execute("DELETE FROM users WHERE user = $user", user = user) @SC.subcommand( @@ -118,11 +135,12 @@ def cmd_editsvc(service, rename = None): opts = [SC.Opt('alias', '-a', '--alias', "alias by which USER is known to SERVICE", argname = 'ALIAS')], - params = [SC.Arg('user'), SC.Arg('service')]) -def cmd_addacct(user, service, alias = None): + params = [SC.Arg('user'), SC.Arg('service')], + rparam = SC.Arg('fields')) +def cmd_addacct(user, service, fields, alias = None): with D.DB: CU.check_user(user) - CU.check_service(service) + svc = CU.check_service(service) D.DB.execute("""SELECT 1 FROM services WHERE user = $user AND service = $service""", user = user, service = service) @@ -133,18 +151,32 @@ def cmd_addacct(user, service, alias = None): VALUES ($service, $user, $alias)""", service = service, user = user, alias = alias) + if svc.manage_pwent_p: + if alias is None: alias = user + passwd = CFG.RQCLASS.reset([OP.acct(svc, alias)]).pwgen() + svc.mkpwent(alias, passwd, fields) + elif fields: + raise U.ExpectedError, ( + 400, "Password entry fields supplied, " + "but `%s' entries must be created manually" % service) + @SC.subcommand( 'delacct', ['admin'], "Remove USER's SERVICE account.", params = [SC.Arg('user'), SC.Arg('service')]) def cmd_delacct(user, service): with D.DB: - CU.resolve_account(service, user) + svc, alias = CU.resolve_account(service, user) if service == 'master': raise U.ExpectedError, \ (400, "Can't delete master accounts: use `deluser'") D.DB.execute("""DELETE FROM services WHERE service = $service AND user = $user""", service = service, user = user) + if svc.manage_pwent_p: + svc.rmpwent(alias) + else: + OUT.warn("You must remove the `%s' password entry for `%s' by hand" % + (service, user)) @SC.subcommand( 'editacct', ['admin'], "Modify USER's SERVICE account record.", diff --git a/cmd-remote.py b/cmd-remote.py index c8f0aed..e90f400 100644 --- a/cmd-remote.py +++ b/cmd-remote.py @@ -42,4 +42,20 @@ def cmd_set_svc(service, user): svc = CU.check_service(service) svc.clearpasswd(user) +@SC.subcommand( + 'mkpwent', ['remote'], 'Create a new user record', + params = [SC.Arg('user'), SC.Arg('service')], + rparam = SC.Arg('fields')) +def cmd_mkpwent_svc(user, service, fields): + passwd = U.readline('new password') + svc = CU.check_service(service) + svc.mkpwent(user, passwd, fields) + +@SC.subcommand( + 'rmpwent', ['remote'], 'Remove an existing user record', + params = [SC.Arg('user'), SC.Arg('service')]) +def cmd_rmpwent_svc(user, service): + svc = CU.check_service(service) + svc.rmpwent(user) + ###----- That's all, folks -------------------------------------------------- diff --git a/service.py b/service.py index aa77388..ddd28b9 100644 --- a/service.py +++ b/service.py @@ -40,11 +40,10 @@ import util as U ### Protocol. ### ### A service is a thing for which a user might have an account, with a login -### name and password. The service protocol is fairly straightforward: a -### password can be set to a particular value using `setpasswd' (which -### handles details of hashing and so on), or cleared (i.e., preventing -### logins using a password) using `clearpasswd'. Services also present -### `friendly' names, used by the user interface. +### name and password. The service protocol is fairly straightforward: there +### are methods corresponding to the various low-level operations which can +### be performed on services. Services also present `friendly' names, used +### by the user interface. ### ### A service may be local or remote. Local services are implemented in ### terms of a backend and hashing scheme. Information about a particular @@ -76,12 +75,18 @@ class IncorrectPassword (Exception): class BasicService (object): """ A simple base class for services. + + The `manage_pwent_p' flag indicates whether administration commands should + attempt to add or remove password entries in the corresponding database + when users are added or removed. """ - def __init__(me, friendly, name = None, *args, **kw): + def __init__(me, friendly, name = None, manage_pwent_p = True, + *args, **kw): super(BasicService, me).__init__(*args) me.name = name me.friendly = friendly + me.manage_pwent_p = manage_pwent_p me.meta = kw ###-------------------------------------------------------------------------- @@ -128,6 +133,10 @@ class Account (object): me._rec.passwd = passwd me._rec.write() + def remove(me): + """Service protocol: remove the user's password entry.""" + me._rec.remove() + class LocalService (BasicService): """ A local service has immediate knowledge of a hashing scheme and a password @@ -162,6 +171,15 @@ class LocalService (BasicService): """Service protocol: clear USER's password, preventing logins.""" me.find(user).clearpasswd() + def mkpwent(me, user, passwd, fields): + """Service protocol: create a record for USER.""" + if me.hash.NULL is not None: passwd = me.hash.NULL + me._be.create(user, passwd, fields) + + def rmpwent(me, user): + """Service protocol: delete the record for USER.""" + me.find(user).remove() + CONF.export('LocalService') ###-------------------------------------------------------------------------- @@ -300,6 +318,14 @@ class SSHRemoteService (BasicRemoteService): """Service protocol: clear the USER's password.""" me._run_noout(['clear', me.name, user]) + def mkpwent(me, user, passwd, fields): + """Service protocol: create a record for USER.""" + me._run_noout(['mkpwent', user, me.name] + fields, passwd + '\n') + + def rmpwent(me, user): + """Service protocol: delete the record for USER.""" + me._run_noout(['rmpwent', user, me.name]) + CONF.export('SSHRemoteService') class CommandRemoteService (BasicRemoteService): @@ -380,6 +406,14 @@ class CommandRemoteService (BasicRemoteService): """Service protocol: clear the USER's password.""" me._dispatch(me._run_noout, 'clear', [('u', user)]) + def mkpwent(me, user, passwd, fields): + """Service protocol: create a record for USER.""" + me._dispatch(me._run_noout, 'mkpwent', [('u', user)]) + + def rmpwent(me, user): + """Service protocol: delete the record for USER.""" + me._dispatch(me._run_noout, 'rmpwent', [('u', user)]) + CONF.export('CommandRemoteService') ###-------------------------------------------------------------------------- -- [mdw]