Automatically add and remove password database records.
authorMark Wooding <mdw@distorted.org.uk>
Sat, 24 May 2014 13:00:03 +0000 (14:00 +0100)
committerMark Wooding <mdw@distorted.org.uk>
Wed, 11 Jun 2014 13:11:29 +0000 (14:11 +0100)
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
cmd-admin.py
cmd-remote.py
service.py

index 54c5374..7c6645a 100644 (file)
@@ -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
@@ -39,6 +40,62 @@ CONF.DEFAULTS.update(
   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.
 ###
 ### A password backend knows how to fetch and modify records in some password
@@ -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()
index 577fe7a..fb72497 100644 (file)
 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.",
index c8f0aed..e90f400 100644 (file)
@@ -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 --------------------------------------------------
index aa77388..ddd28b9 100644 (file)
@@ -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')
 
 ###--------------------------------------------------------------------------