From 000d26d7e323bdfbcf428f6f232d8a03469f64b1 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Mon, 28 Sep 2015 10:53:49 -0400 Subject: [PATCH 1/4] session limit enforcement * upon creating a new session, invalidate oldest sessions --- awx/api/authentication.py | 33 +- awx/api/views.py | 11 +- .../commands/run_socketio_service.py | 4 +- awx/main/migrations/0072_v240_changes.py | 522 ++++++++++++++++++ awx/main/models/organization.py | 96 +++- awx/main/tests/__init__.py | 2 +- awx/main/tests/organizations.py | 46 ++ awx/main/tests/users.py | 41 +- awx/settings/defaults.py | 4 + 9 files changed, 731 insertions(+), 28 deletions(-) create mode 100644 awx/main/migrations/0072_v240_changes.py diff --git a/awx/api/authentication.py b/awx/api/authentication.py index faf8b8d56c..e916ee3831 100644 --- a/awx/api/authentication.py +++ b/awx/api/authentication.py @@ -1,6 +1,10 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +# Django +from django.utils.timezone import now as tz_now +from django.conf import settings + # Django REST Framework from rest_framework import authentication from rest_framework import exceptions @@ -48,6 +52,7 @@ class TokenAuthentication(authentication.TokenAuthentication): return self.authenticate_credentials(auth[1]) def authenticate_credentials(self, key): + now = tz_now() # Retrieve the request hash and token. try: request_hash = self.model.get_request_hash(self.request) @@ -56,21 +61,31 @@ class TokenAuthentication(authentication.TokenAuthentication): request_hash=request_hash, ) except self.model.DoesNotExist: - raise exceptions.AuthenticationFailed('Invalid token') + raise exceptions.AuthenticationFailed(AuthToken.reason_long('invalid_token')) - # Sanity check: Ensure that the token is still valid. - # Tokens expire if they are not used for 30 minutes. - if token.expired: - raise exceptions.AuthenticationFailed('Token is expired') + # Tell the user why their token was previously invalidated. + if token.invalidated: + raise exceptions.AuthenticationFailed(AuthToken.reason_long(token.reason)) - # Sanity check: If the user is inactive, then return an error. + # Explicitly handle expired tokens + if token.is_expired(now=now): + token.invalidate(reason='timeout_reached') + raise exceptions.AuthenticationFailed(AuthToken.reason_long('timeout_reached')) + + # Token invalidated due to session limit config being reduced + # Session limit reached invalidation will also take place on authentication + if settings.AUTH_TOKEN_PER_USER != -1: + if not token.in_valid_tokens(now=now): + token.invalidate(reason='limit_reached') + raise exceptions.AuthenticationFailed(AuthToken.reason_long('limit_reached')) + + # If the user is inactive, then return an error. if not token.user.is_active: raise exceptions.AuthenticationFailed('User inactive or deleted') # Refresh the token. - # This updates the time that the token was last used, meaning that - # now the token is valid for 30 minutes from "right now". - token.refresh() + # The token is extended from "right now" + configurable setting amount. + token.refresh(now=now) # Return the user object and the token. return (token.user, token) diff --git a/awx/api/views.py b/awx/api/views.py index 95a58cca24..6e53ed9997 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -524,9 +524,18 @@ class AuthTokenView(APIView): try: token = AuthToken.objects.filter(user=serializer.object['user'], request_hash=request_hash, - expires__gt=now())[0] + expires__gt=now(), + reason='')[0] token.refresh() except IndexError: + # Get user un-expired tokens that are not invalidated that are + # over the configured limit. + # Mark them as invalid and inform the user + invalid_tokens = AuthToken.get_tokens_over_limit(serializer.object['user']) + for t in invalid_tokens: + # TODO: send socket notification + t.invalidate(reason='limit_reached') + token = AuthToken.objects.create(user=serializer.object['user'], request_hash=request_hash) return Response({'token': token.key, 'expires': token.expires}) diff --git a/awx/main/management/commands/run_socketio_service.py b/awx/main/management/commands/run_socketio_service.py index 4a2ff900a1..f2f0155642 100644 --- a/awx/main/management/commands/run_socketio_service.py +++ b/awx/main/management/commands/run_socketio_service.py @@ -55,11 +55,11 @@ class TowerBaseNamespace(BaseNamespace): k, v = self.environ['QUERY_STRING'].split("=") if k == "Token": token_actual = urllib.unquote_plus(v).decode().replace("\"","") - auth_token = AuthToken.objects.filter(key=token_actual) + auth_token = AuthToken.objects.filter(key=token_actual, reason='') if not auth_token.exists(): return False auth_token = auth_token[0] - if not auth_token.expired: + if not auth_token.is_expired(): return auth_token.user else: return False diff --git a/awx/main/migrations/0072_v240_changes.py b/awx/main/migrations/0072_v240_changes.py new file mode 100644 index 0000000000..d6d30695d5 --- /dev/null +++ b/awx/main/migrations/0072_v240_changes.py @@ -0,0 +1,522 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'AuthToken.reason' + db.add_column(u'main_authtoken', 'reason', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'AuthToken.reason' + db.delete_column(u'main_authtoken', 'reason') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'main.activitystream': { + 'Meta': {'object_name': 'ActivityStream'}, + 'actor': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'activity_stream'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'ad_hoc_command': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.AdHocCommand']", 'symmetrical': 'False', 'blank': 'True'}), + 'changes': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'credential': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Credential']", 'symmetrical': 'False', 'blank': 'True'}), + 'custom_inventory_script': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.CustomInventoryScript']", 'symmetrical': 'False', 'blank': 'True'}), + 'group': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'host': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Host']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Inventory']", 'symmetrical': 'False', 'blank': 'True'}), + 'inventory_source': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.InventorySource']", 'symmetrical': 'False', 'blank': 'True'}), + 'inventory_update': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.InventoryUpdate']", 'symmetrical': 'False', 'blank': 'True'}), + 'job': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Job']", 'symmetrical': 'False', 'blank': 'True'}), + 'job_template': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.JobTemplate']", 'symmetrical': 'False', 'blank': 'True'}), + 'object1': ('django.db.models.fields.TextField', [], {}), + 'object2': ('django.db.models.fields.TextField', [], {}), + 'object_relationship_type': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'operation': ('django.db.models.fields.CharField', [], {'max_length': '13'}), + 'organization': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Organization']", 'symmetrical': 'False', 'blank': 'True'}), + 'permission': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Project']", 'symmetrical': 'False', 'blank': 'True'}), + 'project_update': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.ProjectUpdate']", 'symmetrical': 'False', 'blank': 'True'}), + 'schedule': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Schedule']", 'symmetrical': 'False', 'blank': 'True'}), + 'team': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Team']", 'symmetrical': 'False', 'blank': 'True'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'unified_job': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'activity_stream_as_unified_job+'", 'blank': 'True', 'to': "orm['main.UnifiedJob']"}), + 'unified_job_template': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'activity_stream_as_unified_job_template+'", 'blank': 'True', 'to': "orm['main.UnifiedJobTemplate']"}), + 'user': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'main.adhoccommand': { + 'Meta': {'object_name': 'AdHocCommand', '_ormbases': ['main.UnifiedJob']}, + 'become_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'ad_hoc_commands'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Credential']"}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ad_hoc_commands'", 'symmetrical': 'False', 'through': "orm['main.AdHocCommandEvent']", 'to': "orm['main.Host']"}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'ad_hoc_commands'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_type': ('django.db.models.fields.CharField', [], {'default': "'run'", 'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'module_args': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'module_name': ('django.db.models.fields.CharField', [], {'default': "'command'", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.adhoccommandevent': { + 'Meta': {'ordering': "('-pk',)", 'unique_together': "[('ad_hoc_command', 'host_name')]", 'object_name': 'AdHocCommandEvent'}, + 'ad_hoc_command': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'ad_hoc_command_events'", 'to': "orm['main.AdHocCommand']"}), + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'counter': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'event': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'event_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'ad_hoc_command_events'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Host']"}), + 'host_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}) + }, + 'main.authtoken': { + 'Meta': {'object_name': 'AuthToken'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'expires': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '40', 'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'request_hash': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '40', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'auth_tokens'", 'to': u"orm['auth.User']"}) + }, + 'main.credential': { + 'Meta': {'ordering': "('kind', 'name')", 'unique_together': "[('user', 'team', 'kind', 'name')]", 'object_name': 'Credential'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'become_method': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'become_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'become_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'cloud': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'host': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'kind': ('django.db.models.fields.CharField', [], {'default': "'ssh'", 'max_length': '32'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'ssh_key_data': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'ssh_key_unlock': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'credentials'", 'null': 'True', 'blank': 'True', 'to': "orm['main.Team']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'credentials'", 'null': 'True', 'blank': 'True', 'to': u"orm['auth.User']"}), + 'username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'vault_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}) + }, + 'main.custominventoryscript': { + 'Meta': {'ordering': "('name',)", 'unique_together': "[('name', 'organization')]", 'object_name': 'CustomInventoryScript'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'custominventoryscript\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'custominventoryscript\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'custom_inventory_scripts'", 'to': "orm['main.Organization']"}), + 'script': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.group': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('name', 'inventory'),)", 'object_name': 'Group'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'groups_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'has_inventory_sources': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'groups'", 'blank': 'True', 'to': "orm['main.Host']"}), + 'hosts_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'groups'", 'to': "orm['main.Inventory']"}), + 'inventory_sources': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'groups'", 'symmetrical': 'False', 'to': "orm['main.InventorySource']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'parents': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'children'", 'blank': 'True', 'to': "orm['main.Group']"}), + 'total_groups': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'total_hosts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.host': { + 'Meta': {'ordering': "('inventory', 'name')", 'unique_together': "(('name', 'inventory'),)", 'object_name': 'Host'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'has_inventory_sources': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'instance_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts'", 'to': "orm['main.Inventory']"}), + 'inventory_sources': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'hosts'", 'symmetrical': 'False', 'to': "orm['main.InventorySource']"}), + 'last_job': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'hosts_as_last_job+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Job']"}), + 'last_job_host_summary': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts_as_last_job_summary+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobHostSummary']", 'blank': 'True', 'null': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.instance': { + 'Meta': {'object_name': 'Instance'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'hostname': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '250'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'primary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '40'}) + }, + 'main.inventory': { + 'Meta': {'ordering': "('name',)", 'unique_together': "[('name', 'organization')]", 'object_name': 'Inventory'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'groups_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'has_inventory_sources': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'hosts_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory_sources_with_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventories'", 'to': "orm['main.Organization']"}), + 'total_groups': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'total_hosts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'total_inventory_sources': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.inventorysource': { + 'Meta': {'object_name': 'InventorySource', '_ormbases': ['main.UnifiedJobTemplate']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventorysources'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'group': ('awx.main.fields.AutoOneToOneField', [], {'default': 'None', 'related_name': "'inventory_source'", 'unique': 'True', 'null': 'True', 'to': "orm['main.Group']"}), + 'group_by': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'instance_filters': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'inventory_sources'", 'null': 'True', 'to': "orm['main.Inventory']"}), + 'overwrite': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'overwrite_vars': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'source': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'source_path': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_regions': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_script': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['main.CustomInventoryScript']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}), + 'source_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}), + 'update_cache_timeout': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'update_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'main.inventoryupdate': { + 'Meta': {'object_name': 'InventoryUpdate', '_ormbases': ['main.UnifiedJob']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventoryupdates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'group_by': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'instance_filters': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'inventory_source': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventory_updates'", 'to': "orm['main.InventorySource']"}), + 'license_error': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'overwrite': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'overwrite_vars': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'source': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'source_path': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_regions': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_script': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['main.CustomInventoryScript']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}), + 'source_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.job': { + 'Meta': {'ordering': "('id',)", 'object_name': 'Job', '_ormbases': ['main.UnifiedJob']}, + 'become_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'cloud_credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs_as_cloud_credential+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'force_handlers': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'jobs'", 'symmetrical': 'False', 'through': "orm['main.JobHostSummary']", 'to': "orm['main.Host']"}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobTemplate']", 'blank': 'True', 'null': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'default': "'run'", 'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'playbook': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Project']", 'blank': 'True', 'null': 'True'}), + 'skip_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'start_at_task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.jobevent': { + 'Meta': {'ordering': "('pk',)", 'object_name': 'JobEvent'}, + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'counter': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'event': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'event_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'job_events_as_primary_host'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Host']"}), + 'host_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'job_events'", 'symmetrical': 'False', 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events'", 'to': "orm['main.Job']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'children'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.JobEvent']"}), + 'play': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'role': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}) + }, + 'main.jobhostsummary': { + 'Meta': {'ordering': "('-pk',)", 'unique_together': "[('job', 'host_name')]", 'object_name': 'JobHostSummary'}, + 'changed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'dark': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'job_host_summaries'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Host']"}), + 'host_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_host_summaries'", 'to': "orm['main.Job']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'ok': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'processed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'skipped': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'main.joborigin': { + 'Meta': {'object_name': 'JobOrigin'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'instance': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['main.Instance']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'unified_job': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'job_origin'", 'unique': 'True', 'to': "orm['main.UnifiedJob']"}) + }, + 'main.jobtemplate': { + 'Meta': {'ordering': "('name',)", 'object_name': 'JobTemplate', '_ormbases': ['main.UnifiedJobTemplate']}, + 'ask_variables_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'become_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'cloud_credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates_as_cloud_credential+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'force_handlers': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'host_config_key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'default': "'run'", 'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'playbook': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Project']", 'blank': 'True', 'null': 'True'}), + 'skip_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'start_at_task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'survey_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'survey_spec': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.organization': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Organization'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'admins': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'admin_of_organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': "orm['main.Project']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.permission': { + 'Meta': {'object_name': 'Permission'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Project']"}), + 'run_ad_hoc_commands': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Team']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}) + }, + 'main.profile': { + 'Meta': {'object_name': 'Profile'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ldap_dn': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'user': ('awx.main.fields.AutoOneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.project': { + 'Meta': {'ordering': "('id',)", 'object_name': 'Project', '_ormbases': ['main.UnifiedJobTemplate']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'projects'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'local_path': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'scm_branch': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'blank': 'True'}), + 'scm_clean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_next_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '8', 'blank': 'True'}), + 'scm_update_cache_timeout': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'scm_update_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.projectupdate': { + 'Meta': {'object_name': 'ProjectUpdate', '_ormbases': ['main.UnifiedJob']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'projectupdates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'local_path': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'project_updates'", 'to': "orm['main.Project']"}), + 'scm_branch': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'blank': 'True'}), + 'scm_clean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '8', 'blank': 'True'}), + 'scm_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.schedule': { + 'Meta': {'ordering': "['-next_run']", 'object_name': 'Schedule'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'schedule\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'dtend': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'dtstart': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'extra_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'schedule\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'next_run': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'rrule': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'unified_job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'schedules'", 'to': "orm['main.UnifiedJobTemplate']"}) + }, + 'main.systemjob': { + 'Meta': {'ordering': "('id',)", 'object_name': 'SystemJob', '_ormbases': ['main.UnifiedJob']}, + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'system_job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.SystemJobTemplate']", 'blank': 'True', 'null': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.systemjobtemplate': { + 'Meta': {'object_name': 'SystemJobTemplate', '_ormbases': ['main.UnifiedJobTemplate']}, + 'job_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.team': { + 'Meta': {'ordering': "('organization__name', 'name')", 'unique_together': "[('organization', 'name')]", 'object_name': 'Team'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'teams'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Organization']"}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': "orm['main.Project']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.unifiedjob': { + 'Meta': {'object_name': 'UnifiedJob'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjob\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'dependent_jobs': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'dependent_jobs_rel_+'", 'to': "orm['main.UnifiedJob']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'elapsed': ('django.db.models.fields.DecimalField', [], {'max_digits': '12', 'decimal_places': '3'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'finished': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job_args': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'job_cwd': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_env': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'job_explanation': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'launch_type': ('django.db.models.fields.CharField', [], {'default': "'manual'", 'max_length': '20'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjob\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'old_pk': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'null': 'True'}), + 'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'polymorphic_main.unifiedjob_set'", 'null': 'True', 'to': u"orm['contenttypes.ContentType']"}), + 'result_stdout_file': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_stdout_text': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_traceback': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'schedule': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['main.Schedule']", 'null': 'True', 'on_delete': 'models.SET_NULL'}), + 'start_args': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'started': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'new'", 'max_length': '20'}), + 'unified_job_template': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjob_unified_jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.UnifiedJobTemplate']"}) + }, + 'main.unifiedjobtemplate': { + 'Meta': {'unique_together': "[('polymorphic_ctype', 'name')]", 'object_name': 'UnifiedJobTemplate'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjobtemplate\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'current_job': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjobtemplate_as_current_job+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.UnifiedJob']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_schedules': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_job': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjobtemplate_as_last_job+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.UnifiedJob']"}), + 'last_job_failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_job_run': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjobtemplate\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'next_job_run': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'next_schedule': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjobtemplate_as_next_schedule+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Schedule']"}), + 'old_pk': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'null': 'True'}), + 'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'polymorphic_main.unifiedjobtemplate_set'", 'null': 'True', 'to': u"orm['contenttypes.ContentType']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'ok'", 'max_length': '32'}) + } + } + + complete_apps = ['main'] \ No newline at end of file diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 3e78744ab6..b72a1df45c 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -12,7 +12,8 @@ from django.conf import settings from django.db import models from django.core.urlresolvers import reverse from django.contrib.auth.models import User -from django.utils.timezone import now +from django.utils.timezone import now as tz_now +from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.fields import AutoOneToOneField @@ -164,13 +165,29 @@ class Profile(CreatedModifiedModel): default='', ) +""" +Since expiration and session expiration is event driven a token could be +invalidated for both reasons. Further, we only support a single reason for a +session token being invalid. For this case, mark the token as expired. +Note: Again, because the value of reason is event based. The reason may not be +set (i.e. may equal '') even though a session is expired or a limit is reached. +""" class AuthToken(BaseModel): ''' Custom authentication tokens per user with expiration and request-specific data. ''' + REASON_CHOICES = [ + ('', _('Token not invalidated')), + ('timeout_reached', _('Token is expired')), + ('limit_reached', _('Maximum per-user sessions reached')), + # invalid_token is not a used data-base value, but is returned by the + # api when a token is not found + ('invalid_token', _('Invalid token')), + ] + class Meta: app_label = 'main' @@ -179,8 +196,21 @@ class AuthToken(BaseModel): on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) - expires = models.DateTimeField(default=now) + expires = models.DateTimeField(default=tz_now) request_hash = models.CharField(max_length=40, blank=True, default='') + reason = models.CharField( + max_length=1024, + blank=True, + default='', + help_text=_('Reason the auth token was invalidated.') + ) + + @staticmethod + def reason_long(reason): + for x in AuthToken.REASON_CHOICES: + if x[0] == reason: + return x[1] + return None @classmethod def get_request_hash(cls, request): @@ -201,25 +231,65 @@ class AuthToken(BaseModel): self.key = self.generate_key() return super(AuthToken, self).save(*args, **kwargs) - def refresh(self, save=True): - if not self.pk or not self.expired: - self.expires = now() + datetime.timedelta(seconds=settings.AUTH_TOKEN_EXPIRATION) + def refresh(self, now=None, save=True): + if not now: + now = tz_now() + if not self.pk or not self.is_expired(now=now): + self.expires = now + datetime.timedelta(seconds=settings.AUTH_TOKEN_EXPIRATION) if save: self.save() - def invalidate(self, save=True): - if not self.expired: - self.expires = now() - datetime.timedelta(seconds=1) - if save: - self.save() + def invalidate(self, reason='timeout_reached', save=True): + if not AuthToken.reason_long(reason): + raise ValueError('Invalid reason specified') + self.reason = reason + if save: + self.save() + return reason + + @staticmethod + def get_tokens_over_limit(user, now=None): + if now is None: + now = tz_now() + invalid_tokens = AuthToken.objects.none() + if settings.AUTH_TOKEN_PER_USER != -1: + invalid_tokens = AuthToken.objects.filter( + user=user, + expires__gt=now, + reason='', + ).order_by('-created')[settings.AUTH_TOKEN_PER_USER:] + return invalid_tokens def generate_key(self): unique = uuid.uuid4() return hmac.new(unique.bytes, digestmod=hashlib.sha1).hexdigest() + def is_expired(self, now=None): + if not now: + now = tz_now() + return bool(self.expires < now) + @property - def expired(self): - return bool(self.expires < now()) + def invalidated(self): + return bool(self.reason != '') + + """ + Token is valid if it's in the set of unexpired tokens. + The unexpired token set is: + * tokens not expired + * limited to number of tokens per-user + * sorted by created on date + """ + def in_valid_tokens(self, now=None): + if not now: + now = tz_now() + valid_n_tokens_qs = self.user.auth_tokens.filter( + expires__gt=now, + reason='', + ).order_by('-created')[0:settings.AUTH_TOKEN_PER_USER] + valid_n_tokens = valid_n_tokens_qs.values_list('key', flat=True) + + return bool(self.key in valid_n_tokens) def __unicode__(self): return self.key @@ -231,7 +301,7 @@ def user_mark_inactive(user, save=True): if user.is_active: # Set timestamp to datetime.isoformat() but without the time zone # offset to stay withint the 30 character username limit. - dtnow = now() + dtnow = tz_now() deleted_ts = dtnow.strftime('%Y-%m-%dT%H:%M:%S.%f') user.username = '_d_%s' % deleted_ts user.is_active = False diff --git a/awx/main/tests/__init__.py b/awx/main/tests/__init__.py index d2556c3a35..c3d0fd60a0 100644 --- a/awx/main/tests/__init__.py +++ b/awx/main/tests/__init__.py @@ -1,7 +1,7 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -from awx.main.tests.organizations import OrganizationsTest # noqa +from awx.main.tests.organizations import * # noqa from awx.main.tests.users import * # noqa from awx.main.tests.inventory import * # noqa from awx.main.tests.projects import ProjectsTest, ProjectUpdatesTest # noqa diff --git a/awx/main/tests/organizations.py b/awx/main/tests/organizations.py index cb238a4f79..655fa430c7 100644 --- a/awx/main/tests/organizations.py +++ b/awx/main/tests/organizations.py @@ -1,10 +1,56 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +# Python +from datetime import timedelta + +# Django from django.core.urlresolvers import reverse +from django.test.utils import override_settings +from django.contrib.auth.models import User +from django.utils.timezone import now as tz_now + +# AWX from awx.main.models import * # noqa from awx.main.tests.base import BaseTest +__all__ = ['AuthTokenLimitUnitTest', 'OrganizationsTest'] + +class AuthTokenLimitUnitTest(BaseTest): + + def setUp(self): + self.now = tz_now() + # Times are relative to now + # (key, created on in seconds , expiration in seconds) + self.test_data = [ + # a is implicitly expired + ("a", -1000, -10), + # b's are invalid due to session limit of 3 + ("b", -100, 60), + ("bb", -100, 60), + ("c", -90, 70), + ("d", -80, 80), + ("e", -70, 90), + ] + self.user = User.objects.create_superuser('admin', 'foo@bar.com', 'password') + for key, t_create, t_expire in self.test_data: + AuthToken.objects.create( + user=self.user, + key=key, + request_hash='this_is_a_hash', + created=self.now + timedelta(seconds=t_create), + expires=self.now + timedelta(seconds=t_expire), + ) + super(AuthTokenLimitUnitTest, self).setUp() + + @override_settings(AUTH_TOKEN_PER_USER=3) + def test_get_tokens_over_limit(self): + invalid_tokens = AuthToken.get_tokens_over_limit(self.user, now=self.now) + invalid_keys = [x.key for x in invalid_tokens] + self.assertEqual(len(invalid_keys), 2) + self.assertIn('b', invalid_keys) + self.assertIn('bb', invalid_keys) + class OrganizationsTest(BaseTest): def collection(self): diff --git a/awx/main/tests/users.py b/awx/main/tests/users.py index a2f111d5c6..322c73c522 100644 --- a/awx/main/tests/users.py +++ b/awx/main/tests/users.py @@ -4,18 +4,20 @@ # Python import datetime import urllib +from mock import patch # Django from django.conf import settings from django.contrib.auth.models import User, Group from django.db.models import Q from django.core.urlresolvers import reverse +from django.test.utils import override_settings # AWX from awx.main.models import * # noqa from awx.main.tests.base import BaseTest -__all__ = ['AuthTokenTimeoutTest', 'AuthTokenProxyTest', 'UsersTest', 'LdapTest'] +__all__ = ['AuthTokenTimeoutTest', 'AuthTokenLimitTest', 'AuthTokenProxyTest', 'UsersTest', 'LdapTest'] class AuthTokenTimeoutTest(BaseTest): @@ -38,6 +40,41 @@ class AuthTokenTimeoutTest(BaseTest): self.assertIn('Auth-Token-Timeout', response) self.assertEqual(response['Auth-Token-Timeout'], str(settings.AUTH_TOKEN_EXPIRATION)) +class AuthTokenLimitTest(BaseTest): + def setUp(self): + super(AuthTokenLimitTest, self).setUp() + self.setup_users() + self.setup_instances() + + @override_settings(AUTH_TOKEN_PER_USER=1) + @patch.object(awx.main.models.organization.AuthToken, 'get_request_hash') + def test_invalidate_first_session(self, mock_get_request_hash): + auth_token_url = reverse('api:auth_token_view') + user_me_url = reverse('api:user_me_list') + + data = dict(zip(('username', 'password'), self.get_normal_credentials())) + + mock_get_request_hash.return_value = "session_1" + response = self.post(auth_token_url, data, expect=200, auth=None) + auth_token1 = { + 'token': response['token'] + } + self.get(user_me_url, expect=200, auth=auth_token1) + + mock_get_request_hash.return_value = "session_2" + response = self.post(auth_token_url, data, expect=200, auth=None) + auth_token2 = { + 'token': response['token'] + } + self.get(user_me_url, expect=200, auth=auth_token2) + + # Ensure our get_request_hash mock is working + self.assertNotEqual(auth_token1['token'], auth_token2['token']) + + mock_get_request_hash.return_value = "session_1" + response = self.get(user_me_url, expect=401, auth=auth_token1) + self.assertEqual(AuthToken.reason_long('limit_reached'), response['detail']) + ''' Ensure ips from the X-Forwarded-For get honored and used in auth tokens ''' @@ -225,7 +262,7 @@ class UsersTest(BaseTest): remote_addr = '127.0.0.2' response = self.get(user_me_url, expect=401, auth=auth_token, remote_addr=remote_addr) - self.assertEqual(response['detail'], 'Invalid token') + self.assertEqual(response['detail'], AuthToken.reason_long('invalid_token')) # The WWW-Authenticate header should specify Token auth, since that # auth method was used in the request. diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index b2d7c295bf..5776b8b088 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -210,6 +210,10 @@ AUTH_LDAP_SERVER_URI = None # Seconds before auth tokens expire. AUTH_TOKEN_EXPIRATION = 1800 +# Maximum number of per-user valid, concurrent session. +# -1 is unlimited +AUTH_TOKEN_PER_USER = -1 + # If set, serve only minified JS for UI. USE_MINIFIED_JS = False From df31991a1a1fe127e009f53bbafc0c0ab0bc261c Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 29 Sep 2015 10:45:25 -0400 Subject: [PATCH 2/4] add docker-compose-test directive to Makefile --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 40d7433eea..8f1b046826 100644 --- a/Makefile +++ b/Makefile @@ -673,3 +673,7 @@ install: # Docker Compose Development environment docker-compose: docker-compose -f tools/docker-compose.yml up --no-recreate + +docker-compose-test: + cd tools && docker-compose run --service-ports tower /bin/bash + From b350eef3f0256245ae888f316dcc639e0a3f24e1 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 29 Sep 2015 14:10:26 -0400 Subject: [PATCH 3/4] session limit invalidation events via socket.io --- awx/api/views.py | 9 +- .../commands/run_socketio_service.py | 170 ++++++++++++++---- awx/main/utils.py | 4 +- 3 files changed, 144 insertions(+), 39 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 6e53ed9997..b1ca33ed27 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -64,6 +64,7 @@ from awx.api.permissions import * # noqa from awx.api.renderers import * # noqa from awx.api.serializers import * # noqa from awx.fact.models import * # noqa +from awx.main.utils import emit_websocket_notification def api_exception_handler(exc): ''' @@ -528,16 +529,20 @@ class AuthTokenView(APIView): reason='')[0] token.refresh() except IndexError: + token = AuthToken.objects.create(user=serializer.object['user'], + request_hash=request_hash) # Get user un-expired tokens that are not invalidated that are # over the configured limit. # Mark them as invalid and inform the user invalid_tokens = AuthToken.get_tokens_over_limit(serializer.object['user']) for t in invalid_tokens: # TODO: send socket notification + emit_websocket_notification('/socket.io/control', + 'limit_reached', + dict(reason=unicode(AuthToken.reason_long('limit_reached'))), + token_key=t.key) t.invalidate(reason='limit_reached') - token = AuthToken.objects.create(user=serializer.object['user'], - request_hash=request_hash) return Response({'token': token.key, 'expires': token.expires}) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/awx/main/management/commands/run_socketio_service.py b/awx/main/management/commands/run_socketio_service.py index f2f0155642..8a4706aea9 100644 --- a/awx/main/management/commands/run_socketio_service.py +++ b/awx/main/management/commands/run_socketio_service.py @@ -5,6 +5,7 @@ import os import logging import urllib +import weakref from optparse import make_option from threading import Thread @@ -24,51 +25,139 @@ from socketio.namespace import BaseNamespace logger = logging.getLogger('awx.main.commands.run_socketio_service') -valid_sockets = [] +class SocketSession(object): + def __init__(self, session_id, token_key, socket): + self.socket = weakref.ref(socket) + self.session_id = session_id + self.token_key = token_key + self._valid = True + def is_valid(self): + return bool(self._valid) + + def invalidate(self): + self._valid = False + + def is_db_token_valid(self): + auth_token = AuthToken.objects.filter(key=self.token_key, reason='') + if not auth_token.exists(): + return False + auth_token = auth_token[0] + return bool(not auth_token.is_expired()) + +class SocketSessionManager(object): + sessions = [] + session_token_key_map = {} + + @classmethod + def _prune(cls): + #cls.sessions = [s for s in cls.sessions if s.is_valid()] + if len(cls.sessions) > 1000: + del cls.session_token_key_map[cls.sessions[0].token_key] + cls.sessions = cls.sessions[1:] + + @classmethod + def lookup(cls, token_key=None): + if not token_key: + raise ValueError("token_key required") + for s in cls.sessions: + if s.token_key == token_key: + return s + return None + + @classmethod + def add_session(cls, session): + cls.sessions.append(session) + cls.session_token_key_map[session.token_key] = session + cls._prune() + +class SocketController(object): + server = None + + @classmethod + def broadcast_packet(cls, packet): + # Broadcast message to everyone at endpoint + # Loop over the 'raw' list of sockets (don't trust our list) + for session_id, socket in list(cls.server.sockets.iteritems()): + socket_session = socket.session.get('socket_session', None) + if socket_session and socket_session.is_valid(): + try: + socket.send_packet(packet) + except Exception, e: + logger.error("Error sending client packet to %s: %s" % (str(session_id), str(packet))) + logger.error("Error was: " + str(e)) + + @classmethod + def send_packet(cls, packet, token_key): + if not token_key: + raise ValueError("token_key is required") + socket_session = SocketSessionManager.lookup(token_key=token_key) + # We may not find the socket_session if the user disconnected + # (it's actually more compliciated than that because of our prune logic) + if socket_session and socket_session.is_valid(): + socket = socket_session.socket() + if socket: + try: + socket.send_packet(packet) + except Exception, e: + logger.error("Error sending client packet to %s: %s" % (str(socket_session.session_id), str(packet))) + logger.error("Error was: " + str(e)) + + @classmethod + def set_server(cls, server): + cls.server = server + return server + +# +# Socket session is attached to self.session['socket_session'] +# self.session and self.socket.session point to the same dict +# class TowerBaseNamespace(BaseNamespace): def get_allowed_methods(self): return ['recv_disconnect'] def get_initial_acl(self): - global valid_sockets - v_user = self.valid_user() - self.is_valid_connection = False - if v_user: - if self.socket.sessid not in valid_sockets: - valid_sockets.append(self.socket.sessid) - self.is_valid_connection = True - if len(valid_sockets) > 1000: - valid_sockets = valid_sockets[1:] + request_token = self._get_request_token() + if request_token: + # (1) This is the first time the socket has been seen (first + # namespace joined). + # (2) This socket has already been seen (already joined and maybe + # left a namespace) + # + # Note: Assume that the user token is valid if the session is found + socket_session = self.session.get('socket_session', None) + if not socket_session: + socket_session = SocketSession(self.socket.sessid, request_token, self.socket) + if socket_session.is_db_token_valid(): + self.session['socket_session'] = socket_session + SocketSessionManager.add_session(socket_session) + else: + socket_session.invalidate() + return set(['recv_connect'] + self.get_allowed_methods()) else: logger.warn("Authentication Failure validating user") self.emit("connect_failed", "Authentication failed") return set(['recv_connect']) - def valid_user(self): + def _get_request_token(self): if 'QUERY_STRING' not in self.environ: return False - else: - try: - k, v = self.environ['QUERY_STRING'].split("=") - if k == "Token": - token_actual = urllib.unquote_plus(v).decode().replace("\"","") - auth_token = AuthToken.objects.filter(key=token_actual, reason='') - if not auth_token.exists(): - return False - auth_token = auth_token[0] - if not auth_token.is_expired(): - return auth_token.user - else: - return False - except Exception, e: - logger.error("Exception validating user: " + str(e)) - return False + + try: + k, v = self.environ['QUERY_STRING'].split("=") + if k == "Token": + token_actual = urllib.unquote_plus(v).decode().replace("\"","") + return token_actual + except Exception, e: + logger.error("Exception validating user: " + str(e)) + return False + return False def recv_connect(self): - if not self.is_valid_connection: + socket_session = self.session.get('socket_session', None) + if socket_session and not socket_session.is_valid(): self.disconnect(silent=False) class TestNamespace(TowerBaseNamespace): @@ -106,6 +195,14 @@ class ScheduleNamespace(TowerBaseNamespace): logger.info("Received client connect for schedule namespace from %s" % str(self.environ['REMOTE_ADDR'])) super(ScheduleNamespace, self).recv_connect() +# Catch-all namespace. +# Deliver 'global' events over this namespace +class ControlNamespace(TowerBaseNamespace): + + def recv_connect(self): + logger.warn("Received client connect for control namespace from %s" % str(self.environ['REMOTE_ADDR'])) + super(ControlNamespace, self).recv_connect() + class TowerSocket(object): def __call__(self, environ, start_response): @@ -115,7 +212,8 @@ class TowerSocket(object): '/socket.io/jobs': JobNamespace, '/socket.io/job_events': JobEventNamespace, '/socket.io/ad_hoc_command_events': AdHocCommandEventNamespace, - '/socket.io/schedules': ScheduleNamespace}) + '/socket.io/schedules': ScheduleNamespace, + '/socket.io/control': ControlNamespace}) else: logger.warn("Invalid connect path received: " + path) start_response('404 Not Found', []) @@ -130,13 +228,12 @@ def notification_handler(server): 'name': message['event'], 'type': 'event', } - for session_id, socket in list(server.sockets.iteritems()): - if session_id in valid_sockets: - try: - socket.send_packet(packet) - except Exception, e: - logger.error("Error sending client packet to %s: %s" % (str(session_id), str(packet))) - logger.error("Error was: " + str(e)) + + if 'token_key' in message: + # Best practice not to send the token over the socket + SocketController.send_packet(packet, message.pop('token_key')) + else: + SocketController.broadcast_packet(packet) class Command(NoArgsCommand): ''' @@ -164,6 +261,7 @@ class Command(NoArgsCommand): logger.info('Listening on port http://0.0.0.0:' + str(socketio_listen_port)) server = SocketIOServer(('0.0.0.0', socketio_listen_port), TowerSocket(), resource='socket.io') + SocketController.set_server(server) handler_thread = Thread(target=notification_handler, args=(server,)) handler_thread.daemon = True handler_thread.start() diff --git a/awx/main/utils.py b/awx/main/utils.py index 202e9989fe..9e6a005dc1 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -389,11 +389,13 @@ def get_system_task_capacity(): return 50 + ((int(total_mem_value) / 1024) - 2) * 75 -def emit_websocket_notification(endpoint, event, payload): +def emit_websocket_notification(endpoint, event, payload, token_key=None): from awx.main.socket import Socket try: with Socket('websocket', 'w', nowait=True, logger=logger) as websocket: + if token_key: + payload['token_key'] = token_key payload['event'] = event payload['endpoint'] = endpoint websocket.publish(payload) From 4163492e815ad40beae5b5b02fe4f1f59fef28d4 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 29 Sep 2015 15:49:42 -0400 Subject: [PATCH 4/4] add auth timeout header to login response --- awx/api/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/awx/api/views.py b/awx/api/views.py index b1ca33ed27..091262964e 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -543,7 +543,12 @@ class AuthTokenView(APIView): token_key=t.key) t.invalidate(reason='limit_reached') - return Response({'token': token.key, 'expires': token.expires}) + # Note: This header is normally added in the middleware whenever an + # auth token is included in the request header. + headers = { + 'Auth-Token-Timeout': int(settings.AUTH_TOKEN_EXPIRATION) + } + return Response({'token': token.key, 'expires': token.expires}, headers=headers) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class OrganizationList(ListCreateAPIView):