#3543 Create new session when old session was timeout
Closed a year ago by tkopecek. Opened 2 years ago by jcupova.
jcupova/koji issue-3394  into  master

@@ -2,8 +2,11 @@ 

  -- from version 1.31 to 1.32

  

  BEGIN;

- 

      -- fix duplicate extension in archivetypes

      UPDATE archivetypes SET extensions = 'vhdx.gz vhdx.xz' WHERE name = 'vhdx-compressed';

  

+     -- for tag if session is closed or not

+     ALTER TABLE sessions ADD COLUMN closed BOOLEAN NOT NULL DEFAULT FALSE;

+     ALTER TABLE sessions ADD CONSTRAINT no_closed_exclusive CHECK (closed IS FALSE OR "exclusive" IS NULL);

+     ALTER TABLE sessions DROP CONSTRAINT exclusive_expired_sane;

  COMMIT;

file modified
+3 -2
@@ -119,10 +119,11 @@ 

  	start_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  	update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),

  	exclusive BOOLEAN CHECK (exclusive),

+ 	closed BOOLEAN NOT NULL DEFAULT FALSE,

  	CONSTRAINT no_exclusive_subsessions CHECK (

  		master IS NULL OR "exclusive" IS NULL),

- 	CONSTRAINT exclusive_expired_sane CHECK (

- 		expired IS FALSE OR "exclusive" IS NULL),

+ 	CONSTRAINT no_closed_exclusive CHECK (

+ 		closed IS FALSE OR "exclusive" IS NULL),

  	UNIQUE (user_id,exclusive)

  ) WITHOUT OIDS;

  CREATE INDEX sessions_master ON sessions(master);

file modified
+117 -20
@@ -2422,7 +2422,13 @@ 

  

  class ClientSession(object):

  

-     def __init__(self, baseurl, opts=None, sinfo=None):

+     def __init__(self, baseurl, opts=None, sinfo=None, auth_method=None):

+         """

+         :param baseurl str: hub url

+         :param dict opts: dictionary with content varying according to authentication method

+         :param dict sinfo: session info returned by login method

+         :param dict auth_method: method for reauthentication, shouldn't be ever set manually

+         """

          assert baseurl, "baseurl argument must not be empty"

          if opts is None:

              opts = {}
@@ -2442,6 +2448,8 @@ 

          self.rsession = None

          self.new_session()

          self.opts.setdefault('timeout', DEFAULT_REQUEST_TIMEOUT)

+         self.exclusive = False

+         self.auth_method = auth_method

  

      @property

      def multicall(self):
@@ -2478,8 +2486,21 @@ 

              self.callnum = 0

          self.sinfo = sinfo

  

-     def login(self, opts=None):

-         sinfo = self.callMethod('login', self.opts['user'], self.opts['password'], opts)

+     def login(self, opts=None, renew=False):

+         """

+         Username/password based login method

+ 

+         :param dict opts: dict used by hub "login" call, currently can

+                           contain only "host_ip" key.

+         :returns bool True: success or raises exception

+         """

+         # store calling parameters

+         self.auth_method = {'method': 'login', 'kwargs': {'opts': opts}}

+         kwargs = {'opts': opts}

+         if renew:

+             kwargs['renew'] = True

+             kwargs['exclusive'] = self.exclusive

+         sinfo = self.callMethod('login', self.opts['user'], self.opts['password'], **kwargs)

          if not sinfo:

              return False

          self.setSession(sinfo)
@@ -2489,14 +2510,32 @@ 

      def subsession(self):

          "Create a subsession"

          sinfo = self.callMethod('subsession')

-         return type(self)(self.baseurl, self.opts, sinfo)

+         return type(self)(self.baseurl, opts=self.opts, sinfo=sinfo, auth_method=self.auth_method)

  

      def gssapi_login(self, principal=None, keytab=None, ccache=None,

-                      proxyuser=None, proxyauthtype=None):

+                      proxyuser=None, proxyauthtype=None, renew=False):

+         """

+         GSSAPI/Kerberos login method

+ 

+         :param str principal: Kerberos principal

+         :param str keytab: path to keytab file

+         :param str ccache: path to ccache file/dir

+         :param str proxyuser: name of proxied user (e.g. forwarding by web ui)

+         :param int proxyauthtype: AUTHTYPE used by proxied user (can be different from ours)

+         :returns bool True: success or raises exception

+         """

          if not reqgssapi:

              raise PythonImportError(

                  "Please install python-requests-gssapi to use GSSAPI."

              )

+         # store calling parameters

+         self.auth_method = {

+             'method': 'gssapi_login',

+             'kwargs': {

+                 'principal': principal, 'keytab': keytab, 'ccache': ccache, 'proxyuser': proxyuser,

+                 'proxyauthtype': proxyauthtype

+             }

+         }

          # force https

          old_baseurl = self.baseurl

          uri = six.moves.urllib.parse.urlsplit(self.baseurl)
@@ -2538,6 +2577,9 @@ 

                  # For this case we're now using retry=False and test errors for

                  # this exact usecase.

                  kwargs = {'proxyuser': proxyuser}

+                 if renew:

+                     kwargs['renew'] = True

+                     kwargs['exclusive'] = self.exclusive

                  if proxyauthtype is not None:

                      kwargs['proxyauthtype'] = proxyauthtype

                  for tries in range(self.opts.get('max_retries', 30)):
@@ -2585,7 +2627,26 @@ 

          self.authtype = AUTHTYPES['GSSAPI']

          return True

  

-     def ssl_login(self, cert=None, ca=None, serverca=None, proxyuser=None, proxyauthtype=None):

+     def ssl_login(self, cert=None, ca=None, serverca=None, proxyuser=None, proxyauthtype=None,

+                   renew=False):

+         """

+         SSL cert based login

+ 

+         :param str cert: path to SSL certificate

+         :param str ca: deprecated, not used anymore

+         :param str serverca: path for CA public cert, otherwise system-wide CAs are used

+         :param str proxyuser: name of proxied user (e.g. forwarding by web ui)

+         :param int proxyauthtype: AUTHTYPE used by proxied user (can be different from ours)

+         :returns bool: success

+         """

+         # store calling parameters

+         self.auth_method = {

+             'method': 'ssl_login',

+             'kwargs': {

+                 'cert': cert, 'ca': ca, 'serverca': serverca,

+                 'proxyuser': proxyuser, 'proxyauthtype': proxyauthtype,

+             }

+         }

          cert = cert or self.opts.get('cert')

          serverca = serverca or self.opts.get('serverca')

          if cert is None:
@@ -2615,6 +2676,9 @@ 

          e_str = None

          try:

              kwargs = {'proxyuser': proxyuser}

+             if renew:

+                 kwargs['renew'] = True

+                 kwargs['exclusive'] = self.exclusive

              if proxyauthtype is not None:

                  kwargs['proxyauthtype'] = proxyauthtype

              sinfo = self._callMethod('sslLogin', [], kwargs)
@@ -2626,6 +2690,7 @@ 

              sinfo = None

          finally:

              self.opts = old_opts

+ 

          if not sinfo:

              err = 'unable to obtain a session'

              if e_str:
@@ -2705,24 +2770,28 @@ 

              return self._prepUpload(*args, **kwargs)

          args = encode_args(*args, **kwargs)

          headers = []

-         if self.logged_in:

+ 

+         sinfo = None

+         if getattr(self, 'sinfo') is not None:

+             # session renewal (not logged in, but have session data)

+             # makes sense only for new method/server

              sinfo = self.sinfo.copy()

              sinfo['callnum'] = self.callnum

              self.callnum += 1

-             if sinfo.get('header-auth'):

-                 handler = self.baseurl

-                 headers += [

-                     ('Koji-Session-Id', str(self.sinfo['session-id'])),

-                     ('Koji-Session-Key', str(self.sinfo['session-key'])),

-                     ('Koji-Session-Callnum', str(sinfo['callnum'])),

-                 ]

-             else:

-                 # old server

-                 handler = "%s?%s" % (self.baseurl, six.moves.urllib.parse.urlencode(sinfo))

-         elif name == 'sslLogin':

+             headers += [

+                 ('Koji-Session-Id', str(sinfo['session-id'])),

+                 ('Koji-Session-Key', str(sinfo['session-key'])),

+                 ('Koji-Session-Callnum', str(sinfo['callnum'])),

+             ]

+ 

+         if self.logged_in and not self.sinfo.get('header-auth'):

+             # old server

+             handler = "%s?%s" % (self.baseurl, six.moves.urllib.parse.urlencode(sinfo))

+         elif name in 'sslLogin':

              handler = self.baseurl + '/ssllogin'

          else:

              handler = self.baseurl

+ 

          request = dumps(args, name, allow_none=1)

          if six.PY3:

              # For python2, dumps() without encoding specified means return a str
@@ -2831,9 +2900,30 @@ 

              result = result[0]

          return result

  

+     def _renew_session(self):

+         """Renew expirated session or subsession."""

+         if not hasattr(self, 'auth_method'):

+             raise GenericError("Missing info for reauthentication")

+         auth_method = getattr(self, self.auth_method['method'])

+         args = self.auth_method.get('args', [])

+         kwargs = self.auth_method.get('kwargs', {})

+         kwargs['renew'] = True

+         self.logged_in = False

+         auth_method(*args, **kwargs)

+ 

+     def renew_expired_session(func):

+         """Decorator to renew expirated session or subsession."""

+         def _renew_expired_session(*args, **kwargs):

+             try:

+                 return func(*args, **kwargs)

+             except AuthExpired:

+                 args[0]._renew_session()

+                 return func(*args, **kwargs)

+         return _renew_expired_session

+ 

+     @renew_expired_session

      def _callMethod(self, name, args, kwargs=None, retry=True):

          """Make a call to the hub with retries and other niceties"""

- 

          if self.multicall:

              if kwargs is None:

                  kwargs = {}
@@ -2869,7 +2959,9 @@ 

                              # server correctly reporting an outage

                              tries = 0

                              continue

-                     raise err

+                     else:

+                         raise err

+ 

                  except (SystemExit, KeyboardInterrupt):

                      # (depending on the python version, these may or may not be subclasses of

                      # Exception)
@@ -3181,6 +3273,11 @@ 

          result = self.callMethod('downloadTaskOutput', taskID, fileName, **dlopts)

          return base64.b64decode(result)

  

+     def exclusiveSession(self, force=False):

+         """Make this session exclusive"""

+         self._callMethod('exclusiveSession', {'force': force})

+         self.exclusive = True

+ 

  

  class MultiCallHack(object):

      """Workaround of a terribly overloaded namespace

file modified
+73 -42
@@ -56,6 +56,8 @@ 

      'repoProblem',

  ]

  

+ AUTH_METHODS = ['login', 'sslLogin']

+ 

  logger = logging.getLogger('koji.auth')

  

  
@@ -82,8 +84,8 @@ 

          args = environ.get('QUERY_STRING', '')

          # prefer new header-based sessions

          if 'HTTP_KOJI_SESSION_ID' in environ:

-             id = int(environ['HTTP_KOJI_SESSION_ID'])

-             key = environ['HTTP_KOJI_SESSION_KEY']

+             self.id = int(environ['HTTP_KOJI_SESSION_ID'])

+             self.key = environ['HTTP_KOJI_SESSION_KEY']

              try:

                  callnum = int(environ['HTTP_KOJI_CALLNUM'])

              except KeyError:
@@ -96,8 +98,8 @@ 

                  return

              args = urllib.parse.parse_qs(args, strict_parsing=True)

              try:

-                 id = int(args['session-id'][0])

-                 key = args['session-key'][0]

+                 self.id = int(args['session-id'][0])

+                 self.key = args['session-key'][0]

              except KeyError as field:

                  raise koji.AuthError('%s not specified in session args' % field)

              try:
@@ -118,35 +120,38 @@ 

          columns, aliases = zip(*fields)

  

          query = QueryProcessor(tables=['sessions'], columns=columns, aliases=aliases,

-                                clauses=['id = %(id)i', 'key = %(key)s', 'hostip = %(hostip)s'],

-                                values={'id': id, 'key': key, 'hostip': hostip},

+                                clauses=['id = %(id)i', 'key = %(key)s', 'hostip = %(hostip)s',

+                                         'closed IS FALSE'],

+                                values={'id': self.id, 'key': self.key, 'hostip': hostip},

                                 opts={'rowlock': True})

          session_data = query.executeOne(strict=False)

          if not session_data:

              query = QueryProcessor(tables=['sessions'], columns=['key', 'hostip'],

-                                    clauses=['id = %(id)i'], values={'id': id})

+                                    clauses=['id = %(id)i'], values={'id': self.id})

              row = query.executeOne(strict=False)

              if row:

-                 if key != row['key']:

-                     logger.warning("Session ID %s is not related to session key %s.", id, key)

+                 if self.key != row['key']:

+                     logger.warning("Session ID %s is not related to session key %s.",

+                                    self.id, self.key)

                  elif hostip != row['hostip']:

-                     logger.warning("Session ID %s is not related to host IP %s.", id, hostip)

+                     logger.warning("Session ID %s is not related to host IP %s.", self.id, hostip)

              raise koji.AuthError('Invalid session or bad credentials')

  

          # check for expiration

          if session_data['expired']:

-             raise koji.AuthExpired('session "%i" has expired' % id)

+             if getattr(context, 'method') not in AUTH_METHODS:

+                 raise koji.AuthExpired(f'session "{self.id}" has expired')

+ 

          # check for callnum sanity

          if callnum is not None:

              try:

                  callnum = int(callnum)

              except (ValueError, TypeError):

-                 raise koji.AuthError("Invalid callnum: %r" % callnum)

+                 raise koji.AuthError(f"Invalid callnum: {callnum!r}")

              lastcall = session_data['callnum']

              if lastcall is not None:

                  if lastcall > callnum:

-                     raise koji.SequenceError("%d > %d (session %d)"

-                                              % (lastcall, callnum, id))

+                     raise koji.SequenceError(f"{lastcall} > {callnum} (session {self.id})")

                  elif lastcall == callnum:

                      # Some explanation:

                      # This function is one of the few that performs its own commit.
@@ -159,8 +164,11 @@ 

                      method = getattr(context, 'method', 'UNKNOWN')

                      if method not in RetryWhitelist:

                          raise koji.RetryError(

-                             "unable to retry call %d (method %s) for session %d"

-                             % (callnum, method, id))

+                             f"unable to retry call {callnum} "

+                             f"(method {method}) for session {self.id}")

+ 

+         if session_data['expired']:

+             return

  

          # read user data

          # historical note:
@@ -182,7 +190,7 @@ 

              # see if an exclusive session exists

              query = QueryProcessor(tables=['sessions'], columns=['id'],

                                     clauses=['user_id=%(user_id)s', 'exclusive = TRUE',

-                                             'expired = FALSE'],

+                                             'closed = FALSE'],

                                     values=session_data)

              excl_id = query.singleValue(strict=False)

  
@@ -200,19 +208,17 @@ 

  

          # update timestamp

          update = UpdateProcessor('sessions', rawdata={'update_time': 'NOW()'},

-                                  clauses=['id = %(id)i'], values={'id': id})

+                                  clauses=['id = %(id)i'], values={'id': self.id})

          update.execute()

          context.cnx.commit()

          # update callnum (this is deliberately after the commit)

          # see earlier note near RetryError

          if callnum is not None:

              update = UpdateProcessor('sessions', rawdata={'callnum': callnum},

-                                      clauses=['id = %(id)i'], values={'id': id})

+                                      clauses=['id = %(id)i'], values={'id': self.id})

              update.execute()

  

          # record the login data

-         self.id = id

-         self.key = key

          self.hostip = hostip

          self.callnum = callnum

          self.user_id = session_data['user_id']
@@ -278,7 +284,7 @@ 

          if result['status'] != koji.USER_STATUS['NORMAL']:

              raise koji.AuthError('logins by %s are not allowed' % result['name'])

  

-     def login(self, user, password, opts=None):

+     def login(self, user, password, opts=None, renew=False, exclusive=False):

          """create a login session"""

          if opts is None:

              opts = {}
@@ -299,7 +305,9 @@ 

          self.checkLoginAllowed(user_id)

  

          # create session and return

-         sinfo = self.createSession(user_id, hostip, koji.AUTHTYPES['NORMAL'])

+         sinfo = self.createSession(user_id, hostip, koji.AUTHTYPES['NORMAL'], renew=renew)

+         if sinfo and exclusive and not self.exclusive:

+             self.makeExclusive()

          context.cnx.commit()

          return sinfo

  
@@ -324,7 +332,7 @@ 

  

          return (local_ip, local_port, remote_ip, remote_port)

  

-     def sslLogin(self, proxyuser=None, proxyauthtype=None):

+     def sslLogin(self, proxyuser=None, proxyauthtype=None, renew=False, exclusive=None):

  

          """Login into brew via SSL. proxyuser name can be specified and if it is

          allowed in the configuration file then connection is allowed to login as
@@ -410,7 +418,9 @@ 

  

          hostip = self.get_remote_ip()

  

-         sinfo = self.createSession(user_id, hostip, authtype)

+         sinfo = self.createSession(user_id, hostip, authtype, renew=renew)

+         if sinfo and exclusive and not self.exclusive:

+             self.makeExclusive()

          return sinfo

  

      def makeExclusive(self, force=False):
@@ -426,9 +436,9 @@ 

          query = QueryProcessor(tables=['users'], columns=['id'], clauses=['id=%(user_id)s'],

                                 values={'user_id': user_id}, opts={'rowlock': True})

          query.execute()

-         # check that no other sessions for this user are exclusive

+         # check that no other sessions for this user are exclusive (including expired)

          query = QueryProcessor(tables=['sessions'], columns=['id'],

-                                clauses=['user_id=%(user_id)s', 'expired = FALSE',

+                                clauses=['user_id=%(user_id)s', 'closed = FALSE',

                                          'exclusive = TRUE'],

                                 values={'user_id': user_id}, opts={'rowlock': True})

          excl_id = query.singleValue(strict=False)
@@ -455,7 +465,7 @@ 

          context.cnx.commit()

  

      def logout(self, session_id=None):

-         """expire a login session"""

+         """close a login session"""

          if not self.logged_in:

              # XXX raise an error?

              raise koji.AuthError("Not logged in")
@@ -470,7 +480,8 @@ 

              ses_id = session_id

          else:

              ses_id = self.id

-         update = UpdateProcessor('sessions', data={'expired': True, 'exclusive': None},

+         update = UpdateProcessor('sessions',

+                                  data={'expired': True, 'exclusive': None, 'closed': True},

                                   clauses=['id = %(id)i OR master = %(id)i'],

                                   values={'id': ses_id})

          update.execute()
@@ -479,17 +490,18 @@ 

              self.logged_in = False

  

      def logoutChild(self, session_id):

-         """expire a subsession"""

+         """close a subsession"""

          if not self.logged_in:

              # XXX raise an error?

              raise koji.AuthError("Not logged in")

-         update = UpdateProcessor('sessions', data={'expired': True, 'exclusive': None},

+         update = UpdateProcessor('sessions',

+                                  data={'expired': True, 'exclusive': None, 'closed': True},

                                   clauses=['id = %(session_id)i', 'master = %(master)i'],

                                   values={'session_id': session_id, 'master': self.id})

          update.execute()

          context.cnx.commit()

  

-     def createSession(self, user_id, hostip, authtype, master=None):

+     def createSession(self, user_id, hostip, authtype, master=None, renew=False):

          """Create a new session for the given user.

  

          Return a map containing the session-id and session-key.
@@ -501,14 +513,34 @@ 

                           ''.join([random.choice(alnum) for x in range(1, 20)]))

          # use sha? sha.new(phrase).hexdigest()

  

-         # get a session id

-         session_id = nextval('sessions_id_seq')

- 

-         # add session id to database

-         insert = InsertProcessor('sessions',

-                                  data={'id': session_id, 'user_id': user_id, 'key': key,

-                                        'hostip': hostip, 'authtype': authtype, 'master': master})

-         insert.execute()

+         if renew and self.id is not None:

+             # just update key

+             session_id = self.id

+             self.key = key

+             if self.master:

+                 # check if master session died meanwhile (expired is ok)

+                 query = QueryProcessor(tables=['sessions'],

+                                        clauses=['id = %(master_id)d', 'closed IS FALSE'],

+                                        values={'master_id': self.master},

+                                        opts={'countOnly': True})

+                 if query.executeOne() == 0:

+                     return None

+ 

+             update = UpdateProcessor('sessions',

+                                      clauses=['id=%(id)i'],

+                                      rawdata={'update_time': 'NOW()'},

+                                      data={'key': self.key, 'expired': False},

+                                      values={'id': self.id})

+             update.execute()

+         else:

+             # get a session id

+             session_id = nextval('sessions_id_seq')

+             # add session id to database

+             insert = InsertProcessor('sessions',

+                                      data={'id': session_id, 'user_id': user_id, 'key': key,

+                                            'hostip': hostip, 'authtype': authtype,

+                                            'master': master})

+             insert.execute()

          context.cnx.commit()

  

          # return session info
@@ -525,8 +557,7 @@ 

          master = self.master

          if master is None:

              master = self.id

-         return self.createSession(self.user_id, self.hostip, self.authtype,

-                                   master=master)

+         return self.createSession(self.user_id, self.hostip, self.authtype, master=master)

  

      def getPerms(self):

          if not self.logged_in:

file modified
+1 -10
@@ -434,7 +434,7 @@ 

          self.assertEqual(update.table, 'sessions')

          self.assertEqual(update.values, {'id': 123, 'id': 123})

          self.assertEqual(update.clauses, ['id = %(id)i OR master = %(id)i'])

-         self.assertEqual(update.data, {'expired': True, 'exclusive': None})

+         self.assertEqual(update.data, {'closed': True, 'expired': True, 'exclusive': None})

          self.assertEqual(update.rawdata, {})

  

      def test_logoutChild_not_logged(self):
@@ -668,15 +668,6 @@ 

          self.assertEqual(query.clauses, ['active = TRUE', 'user_id=%(user_id)s'])

          self.assertEqual(query.columns, ['name'])

  

-     def test_logout_not_logged(self):

-         s, cntext = self.get_session()

- 

-         # not logged

-         s.logged_in = False

-         with self.assertRaises(koji.AuthError) as ex:

-             s.logout()

-         self.assertEqual("Not logged in", str(ex.exception))

- 

      @mock.patch('koji.auth.context')

      def test_logout_logged_not_owner(self, context):

          s, cntext = self.get_session()

@@ -27,7 +27,7 @@ 

          old_environ = dict(**os.environ)

          self.session.gssapi_login()

          self.session._callMethod.assert_called_with(

-             'sslLogin', [], {'proxyuser': None}, retry=False)

+             'sslLogin', [], {'proxyuser': None, 'session_key': None}, retry=False)

          self.assertEqual(old_environ, dict(**os.environ))

  

      @mock.patch('koji.reqgssapi.HTTPKerberosAuth')
@@ -47,7 +47,7 @@ 

              koji.reqgssapi.__version__ = accepted_version

              rv = self.session.gssapi_login(principal, keytab, ccache)

              self.session._callMethod.assert_called_with(

-                 'sslLogin', [], {'proxyuser': None}, retry=False)

+                 'sslLogin', [], {'proxyuser': None, 'session_key': None}, retry=False)

              self.assertEqual(old_environ, dict(**os.environ))

              self.assertTrue(rv)

              self.session._callMethod.reset_mock()
@@ -84,7 +84,7 @@ 

          with self.assertRaises(koji.GSSAPIAuthError):

              self.session.gssapi_login()

          self.session._callMethod.assert_called_with(

-             'sslLogin', [], {'proxyuser': None}, retry=False)

+             'sslLogin', [], {'proxyuser': None, 'session_key': None}, retry=False)

          self.assertEqual(old_environ, dict(**os.environ))

  

      def test_gssapi_login_http(self):

Few improvements here: https://pagure.io/fork/tkopecek/koji/c/59813a118ce17cbb64e005b65de824f529f4065a?branch=pr3543

What doesn't work nice now is that if (admin) forcefully logout some user it will just reconnect without even knowing that something happened. Not sure if adding some ClientSession option (relogin=True) is worth adding as probably everyone will turn it on. @mikem ?

Note, that we have to add also absolute timeout (probably separate PR)

The subsession question is an interesting one, and it is going to make this more complicated.

Subsessions are serve a couple different purposes:

  1. Keeping parallel access sane — Koji sessions presume sequential calls due the the callnum value, so the best way to make parallel authenticated calls is to use a subession

  2. Giving the main process control over access for its children — The main session can expire its subsessions. When a task is canceled, the main kojid process cancels its subsession, which ensures that the task process can no longer do anything of importance, even if killing it somehow fails.

  3. Linking related sessions — When the main session is logged out, so are its subsessions

It's worth noting that subsessions can be quite long-lived. Their original purpose was for builder tasks, which can run from a few seconds to many hours. However, we now also use them for threads in kojira, which live as long as the kojira process itself does.

Because they are long-lived, we do need a mechanism to renew subsessions, but that mechanism needs to respect the structure here. In particular,

  • the session needs to remain connected to its master session
  • the child task should not be able to renew its session when the main kojid process deliberately logs it out

I'm not sure we're going to be able to to do all this without some api changes and possibly some schema changes.

A few quick remarks on the current patch. Granted these may become irrelevant since we likely need more significant work anyway, but...

  • the code block that handles renewal should probably be separated into a function. That's a lot of complex code to be so deeply nested
  • the session should probably remember how it authenticated the current session so that it can use the same auth again, rather than going by session options

rebased onto 2f8a8bd2bdfdebfc972150f4721034e7c263ed34

a year ago

rebased onto 85ce82a2477b1d14b5ab8b23d0d2d5de7ede8f34

a year ago

rebased onto a1a3704360035cd83e2288ca89b15c3ccaf4dd76

a year ago

rebased onto 81277b845486a3a0899816c22fd588b2cffae8d6

a year ago

rebased onto 2322253dc04d14df5e6cb52849ca1889c8f2b77c

a year ago

rebased onto bfb6b223743b84d8798d1834eb64ee430c79b7d0

a year ago

rebased onto c1207b959ac6e675adf17e7fed5474dab85290fc

a year ago

rebased onto ddfd09fcba619aaf4f9d1beede6a321db3f85872

a year ago

rebased onto 88de54dbf6592fde222e1adf960f984142f62365

a year ago

rebased onto 42b86933056881fce6229d868f5095260b190456

a year ago

2 new commits added

  • Add function mapping
  • store original auth method
a year ago

3 new commits added

  • Add function mapping
  • store original auth method
  • Create new session when old session was timeout
a year ago

3 new commits added

  • Fix call auth_method
  • store original auth method
  • Create new session when old session was timeout
a year ago

rebased onto 1801e5dc8e0ac90719fe2cdf4844c3ffc8dbc221

a year ago

At the moment, this does not fix #3394 because it does not actually implement a timeout. Currently, it only implements a session renewal mechanism. If we're intending to cover #3394 in multiple PRs, we should change that link to Related.

I don't like having the session_key in the args to all these login calls (and therefore also in the auth_method value).

I think we could pass a boolean renew flag instead. The auth.py code can read the session key from the headers as it already does.

I'm concerned about the recursion we're adding in _callMethod. Previously there was no way for this call to recurse. Now there are two pathways:

  1. _callMethod -> _renew_session -> $auth_method -> _callMethod
  2. _callMethod -> _callMethod (after the renew)

_callMethod modifies self._retries in a way that this breaks, though self._retries is only used one place.

This is a relatively minor problem, but after looking at it I think the retry loop just isn't the right place to handle renewal. Instead, we should handle it a layer up. This would either mean moving the logic from _callMethod to a new function or perhaps using a decorator.

Tomas pointed this out above and it's a very good point.

What doesn't work nice now is that if (admin) forcefully logout some user it will just reconnect without even knowing that something happened.

We need a way to distinguish these cases. This will likely require a schema change. Perhaps a closed field for sessions. Alternately, we could enforce timeouts without changing the expired field just based on timestamp check, but this might be confusing.

On top of that, there is still the subsession issue. the current code doesn't allow subsessions to be renewed, which is likely to cause numerous issues.

It would probably be helpful to go ahead and include a session timeout alongside this code. That will give us a good way to test it.

1 new commit added

  • Use self.key and renew arg
a year ago

4 new commits added

  • Add closed column to session table and use it in session
  • Fix call auth_method
  • store original auth method
  • Create new session when old session was timeout
a year ago

1 new commit added

  • Add decorator for renew expired session
a year ago

5 new commits added

  • Add decorator for renew expired session
  • Add closed column to session table and use it in session
  • Fix call auth_method
  • store original auth method
  • Create new session when old session was timeout
a year ago

5 new commits added

  • Add decorator for renew expired session
  • Add closed column to session table and use it in session
  • Fix call auth_method
  • store original auth method
  • Create new session when old session was timeout
a year ago

5 new commits added

  • Add decorator for renew expired session
  • Add closed column to session table and use it in session
  • Fix call auth_method
  • store original auth method
  • Create new session when old session was timeout
a year ago

At the moment, this does not fix #3394 because it does not actually implement a timeout. Currently, it only implements a session renewal mechanism. If we're intending to cover #3394 in multiple PRs, we should change that link to Related.

I renamed issue. This issue works is working without renewal timeout option.

Currently case:
We have cron job, which deletes all items from sessions, which are older than absolute or age value. So we can renew all expirated sessions, which is in the session table currently (not older than 1 day when age is used).

New issue for renewal timeout option: 3596

I don't like having the session_key in the args to all these login calls (and therefore also in the auth_method value).

I think we could pass a boolean renew flag instead. The auth.py code can read the session key from the headers as it already does.

Hm, I tried it, but when we need to you session_key from header, it is cleaned by setSession. Or did you mean other solution how we can use session_key?

I'm concerned about the recursion we're adding in _callMethod. Previously there was no way for this call to recurse. Now there are two pathways:

  1. _callMethod -> _renew_session -> $auth_method -> _callMethod
  2. _callMethod -> _callMethod (after the renew)

_callMethod modifies self._retries in a way that this breaks, though self._retries is only used one place.

This is a relatively minor problem, but after looking at it I think the retry loop just isn't the right place to handle renewal. Instead, we should handle it a layer up. This would either mean moving the logic from _callMethod to a new function or perhaps using a decorator.

Fixed by decorator.

Tomas pointed this out above and it's a very good point.

What doesn't work nice now is that if (admin) forcefully logout some user it will just reconnect without even knowing that something happened.

We need a way to distinguish these cases. This will likely require a schema change. Perhaps a closed field for sessions. Alternately, we could enforce timeouts without changing the expired field just based on timestamp check, but this might be confusing.

Fixed, I added closed column to session table and currently it should be working for logged_in and not closed sessions only.

On top of that, there is still the subsession issue. the current code doesn't allow subsessions to be renewed, which is likely to cause numerous issues.

This was solved earlier in this PR. Code allows renew session and subsession.

It would probably be helpful to go ahead and include a session timeout alongside this code. That will give us a good way to test it.

As I wrote more up, session timeout option will be in the other issue 3596

1 new commit added

  • Fix unit tests
a year ago

rebased onto 22d9066753b1a89a212e87835ce05e6a4d1a3f46

a year ago

It still does not look to me like subsessions are going to work correctly.

When created, subsessions receive a copy of the auth_method dict from the parent. At present, this includes the parent's session_key. This is a problem -- the subsession should not have have access to the parent key.

The first time a subsession hits an AuthError, it will attempt to re-auth using the auth_method data it received at creation. I.e. its parent's, including its parents session key. So, when this login call is performed, it looks to the hub like the parent is renewing. The check in createSession will pull up the parent session, getting the wrong value for master. The client will receive a new session, but will no longer be a subsession.

Once this first renew succeeds, the login call will reset the auth_method data with the new key. So, for subsequent renewals, the session will be renewing as it is is an independent session. The connection to the master session is lost and we lose subsession behavior.

Hm, I tried it, but when we need to you session_key from header, it is cleaned by setSession. Or did you mean other solution how we can use session_key?

I think when we make a call to renew our session, we shouldn't need to clear the old session first.

Getting the subsessions right is going to be tricky. It's important to maintain the connection between subsessions and the master session. Furthermore these sessions operate independently, so they could be renewed concurrently, which could lead to a race depending on how we implement.

I'm thinking it might be safer to rekey a session rather than replace it entirely. This would preserve the subsession connections without us having to update the links.

I still think that session_key should not be in the login options (nor in the auth_method data).

Minor thing, but

@@ -2874,7 +2960,9 @@ class ClientSession(object):
                             # server correctly reporting an outage
                             tries = 0
                             continue
-                    raise err
+                    else:
+                        raise err
+
                 except (SystemExit, KeyboardInterrupt):

This raise does not need to be nested under this else. It is safer to leave it as an unconditional last step of the except clause. Also the extra blank line seems extraneous since the other clauses in the try statement do not have them.

Another minor thing. This debug output should be dropped

self.logger.error("ssl_login---------------")

Some improvements: https://pagure.io/fork/tkopecek/koji/c/02513cb4199b8d1f1b3504b5b93c5e0b98b9774c?branch=pr3543b Anyway, at least one subsession race condition still exist. If master session is exclusive, renewal of its exclusive status is delayed to another call. Subsession can believe that master is still ok and exclusive (but it was just renewed nad makeExclusive was not called yet. It can be improved with putting these two into multicall but it would be tricky.

I've added two thing into the branch
- check that session is not closed
- more problematic one - merge exclusive status renewal with login into the one call. That's probably not needed as it will lower race condition a bit but not much. There still stays situation that non-expired subsession exist and will reach to some exclusive call before master session renew itself. Options are to ignore it (which seems to work, just cluttering log with traceback) or to always check master's status and if it is expired, treat subsession also as expired. Second option could result in some unexpected situations?

2 new commits added

  • renew exclusive status as part of login
  • remove passing session-id
a year ago

Thanks for the updates! I still need to analyze this a bit, as there are a lot of deep changes here, but here are some initial thoughts.

Simple things:

  • new constraint in schema update but not schema
  • raise err is still moved under the else:
  • test_gssapi still asserts session_key args
  • elif name in 'sslLogin':

At present _forget() is a no-op if self.logged_in is false, but now we could be in a state where we still have sinfo. Perhaps best to just drop the logged_in check here, assuming we're going with this overall data design.

I'm a little concerned about possible locking issues calling makeExclusive inside a login call. Looks like a double update.

createSession (and hence the login methods) could not return a None value before. It looks like this with our code, which checks the value already anyway, but it is a nontrivial api change. I wonder it it's actually needed.

Also I'm kind of leaning towards the notion that subsessions should be able to renew themselves even if the master session has not yet. I.e. master session could be expired, but not closed. After all, the client is providing valid credentials for the user.

Also also, when a session goes exclusive, it should probably close those other sessions, not just expire them.

Also I'm kind of leaning towards the notion that subsessions should be able to renew themselves even if the master session has not yet. I.e. master session could be expired, but not closed. After all, the client is providing valid credentials for the user.

So, it means retaining exclusivity for expired master session until nobody else will pick it (dropping the constraint)? Typical case when this matters is daemon thread calling host.* endpoints. createSessions check + return None - if we're going to allow subsession renewal in any case, this could be dropped. In case master session is closed, subsession should be also closed and expiration doesn't matter in that moment.

Regarding subsessions, I see a few major pathways.

First is the one I've suggested. Let subsessions renew themselves when they expire, even if the master session is still expired (but not closed).

Second is to require the master session to be renewed first. The problem here is what does the subsession do if the master has not renewed yet. There's a bit of a race, and I think we can expect to hit it often. Does the session have a special wait loop for this? How long does it wait? It's possible to go this way, but I think it's going to end up being more complex and fragile.

Beyond that, I only see more esoteric refactoring.

So, it means retaining exclusivity for expired master session until nobody else will pick it (dropping the constraint)?

Yeah, I guess so.

In essence, we're giving session.expired a new meaning (timed out but eligible for renewal). What we used to mean by session.expired is now represented by session.closed. It's unfortunate to juggle the terms like this, but "expired" really does suggest the new meaning more than the old.

So I guess we need to allow expired sessions to keep the exclusive flag. That kind of matches the notion above about the old "expired" mapping to "closed" and the new "expired" being a new thing.

rebased onto 2c347b4

a year ago

1 new commit added

  • retain expired session exclusivity
a year ago

I think this is about ready. There are a couple minor things.

The flow in prepCall doesn't seem to match the comments. The if getattr(self, 'sinfo') is not None stanza suggests that it is only for the renewal case, but it happens whenever we have a session, even in the old server case. This is probably ok, but the comment is confusing. Perhaps:

         if getattr(self, 'sinfo') is not None:
-            # session renewal (not logged in, but have session data)
-            # makes sense only for new method/server
+            # send sinfo in headers if we have it
+            # still needed if not logged in for renewal case
             sinfo = self.sinfo.copy()

renew_expired_session should probably be @staticmethod

It seems like it would be more readable to add a self arg to the nested _renew_expired_session so we can use self instead of args[0].

I've mentioned several times in review that the raise err statement should not be nested under an else. It is the default outcome for the Fault handler, not a special case of the if. At present this will result in the code ignoring a ServerOffline fault when offline_retry is False, silently retrying them regardless of setting.

I'm still a little concerned that we have a double update when calling makeExclusive in the login calls, but it looks like we already had double updates there, so 🤷

added updated here: https://pagure.io/fork/tkopecek/koji/c/de4a2aaa64d870212b950fc2355d7d111df2113e?branch=pr3543b

  • else is removed in my branch for some time - it slipped from cherry-picks here
  • I've added SELECT .. FOR UPDATE for session which should? help with double update.

Ah. There are some odd differences between this PR and your branch, perhaps from a rebase. At this point I'm reviewing the pr3543b branch instead of the current content of this PR.

The pr3543b branch addresses all of me concerns above. However, after seeing the added row lock in place, I note that we're already acquiring a row lock on the user table a bit earlier, making the new row lock redundant.

So I think we can move forward with pr3543b, minus the extra row lock.

Opened new PR #3664 directly from that branch. Added commit dropping rowlock query. Closing this PR.

Pull-Request has been closed by tkopecek

a year ago

Metadata Update from @tkopecek:
- Pull-request tagged with: testing-ready

a year ago

Metadata Update from @tkopecek:
- Pull-request untagged with: testing-ready

a year ago