diff --git a/Access/__init__.py b/Access/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Access/admin.py b/Access/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/Access/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/Access/apps.py b/Access/apps.py new file mode 100644 index 00000000..0276054c --- /dev/null +++ b/Access/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccessConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "Access" diff --git a/Access/decorators.py b/Access/decorators.py new file mode 100644 index 00000000..8a33efa2 --- /dev/null +++ b/Access/decorators.py @@ -0,0 +1,37 @@ +from django.core.exceptions import PermissionDenied + + +def user_admin_or_ops(function): + def wrap(request, *args, **kwargs): + if request.user.is_superuser or request.user.user.is_ops: + return function(request, *args, **kwargs) + else: + raise PermissionDenied + + wrap.__doc__ = function.__doc__ + wrap.__name__ = function.__name__ + return wrap + + +def authentication_classes(authentication_classes): + def decorator(func): + func.authentication_classes = authentication_classes + return func + + return decorator + + +def user_with_permission(permissions_list): + def user_with_permission_decorator(function): + def wrap(request, *args, **kwargs): + if hasattr(request.user, "user"): + permission_labels = [ + permission.label for permission in request.user.user.permissions + ] + if len(set(permissions_list).intersection(permission_labels)) > 0: + return function(request, *args, **kwargs) + raise PermissionDenied + + return wrap + + return user_with_permission_decorator diff --git a/Access/helpers.py b/Access/helpers.py new file mode 100644 index 00000000..91a11a58 --- /dev/null +++ b/Access/helpers.py @@ -0,0 +1,30 @@ +from os.path import dirname, basename, isfile, join +import glob +import re +import logging + +logger = logging.getLogger(__name__) +available_accesses = [] +cached_accesses = [] + + +def getAvailableAccessModules(): + global available_accesses + if len(available_accesses) > 0: + return available_accesses + available_accesses = [access for access in getAccessModules() if access.available] + return available_accesses + + +def getAccessModules(): + global cached_accesses + if len(cached_accesses) > 0: + return cached_accesses + access_modules_dirs = glob.glob(join(dirname(__file__), "access_modules", "*")) + for each_dir in access_modules_dirs: + if re.search(r"/(base_|__pycache__)", each_dir): + access_modules_dirs.remove(each_dir) + access_modules_dirs.sort() + cached_accesses = \ + [globals()[basename(f)].access.get_object() for f in access_modules_dirs if not isfile(f)] + return cached_accesses diff --git a/Access/migrations/0001_initial.py b/Access/migrations/0001_initial.py new file mode 100644 index 00000000..87478b42 --- /dev/null +++ b/Access/migrations/0001_initial.py @@ -0,0 +1,500 @@ +# Generated by Django 4.1.3 on 2022-12-19 10:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AccessV2", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("access_tag", models.CharField(max_length=255)), + ("access_label", models.JSONField(default=dict)), + ("is_auto_approved", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="gitAcces", + fields=[ + ("requested_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("requestInfo", models.JSONField(default=dict)), + ( + "status", + models.CharField( + choices=[ + ("Pending", "pending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Processing", "processing"), + ("Revoked", "revoked"), + ], + default="Pending", + max_length=255, + ), + ), + ("requestDateUTC", models.CharField(max_length=255)), + ( + "RevokeDateUTC", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "requestId", + models.CharField(max_length=255, primary_key=True, serialize=False), + ), + ("approver", models.CharField(blank=True, max_length=255)), + ("requester", models.CharField(max_length=255)), + ("reason", models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name="GroupV2", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("group_id", models.CharField(max_length=255, unique=True)), + ("requested_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=128)), + ("description", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("Pending", "pending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Deprecated", "deprecated"), + ], + default="Pending", + max_length=255, + ), + ), + ("decline_reason", models.TextField(blank=True, null=True)), + ("needsAccessApprove", models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name="Permission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name="Role", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=255, unique=True)), + ("permission", models.ManyToManyField(to="Access.permission")), + ], + ), + migrations.CreateModel( + name="SshPublicKey", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.TextField()), + ( + "status", + models.CharField( + choices=[("Active", "active"), ("Revoked", "revoked")], + default="Active", + max_length=100, + ), + ), + ], + ), + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "gitusername", + models.CharField(blank=True, max_length=255, null=True), + ), + ("name", models.CharField(max_length=255, null=True)), + ("email", models.EmailField(max_length=254, null=True)), + ("phone", models.IntegerField(blank=True, null=True)), + ("is_bot", models.BooleanField(default=False)), + ( + "bot_type", + models.CharField( + choices=[("None", "none"), ("Github", "github")], + default="None", + max_length=100, + ), + ), + ("alerts_enabled", models.BooleanField(default=False)), + ("is_manager", models.BooleanField(default=False)), + ("is_ops", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("ssh_pub_key", models.TextField(blank=True, null=True)), + ("avatar", models.TextField(blank=True, null=True)), + ( + "state", + models.CharField( + choices=[ + ("1", "active"), + ("2", "offboarding"), + ("3", "offboarded"), + ], + default=1, + max_length=255, + ), + ), + ("offbaord_date", models.DateTimeField(blank=True, null=True)), + ( + "revoker", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="user_revoker", + to="Access.user", + ), + ), + ("role", models.ManyToManyField(blank=True, to="Access.role")), + ( + "ssh_public_key", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="user", + to="Access.sshpublickey", + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="user", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="UserAccessMapping", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("request_id", models.CharField(max_length=255, unique=True)), + ("requested_on", models.DateTimeField(auto_now_add=True)), + ("approved_on", models.DateTimeField(blank=True, null=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("request_reason", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("Pending", "pending"), + ("SecondaryPending", "secondarypending"), + ("Processing", "processing"), + ("Approved", "approved"), + ("GrantFailed", "grantfailed"), + ("Declined", "declined"), + ("Offboarding", "offboarding"), + ("ProcessingRevoke", "processingrevoke"), + ("RevokeFailed", "revokefailed"), + ("Revoked", "revoked"), + ], + default="Pending", + max_length=100, + ), + ), + ("decline_reason", models.TextField(blank=True, null=True)), + ( + "access_type", + models.CharField( + choices=[("Individual", "individual"), ("Group", "group")], + default="Individual", + max_length=255, + ), + ), + ( + "access", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="Access.accessv2", + ), + ), + ( + "approver_1", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approver_1", + to="Access.user", + ), + ), + ( + "approver_2", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approver_2", + to="Access.user", + ), + ), + ( + "revoker", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="user_access_revoker", + to="Access.user", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="Access.user" + ), + ), + ], + ), + migrations.CreateModel( + name="MembershipV2", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("membership_id", models.CharField(max_length=255, unique=True)), + ("is_owner", models.BooleanField(default=False)), + ("requested_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "status", + models.CharField( + choices=[ + ("Pending", "pending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Revoked", "revoked"), + ], + default="Pending", + max_length=255, + ), + ), + ("reason", models.TextField(blank=True, null=True)), + ("decline_reason", models.TextField(blank=True, null=True)), + ( + "approver", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="membership_approver", + to="Access.user", + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="membership_group", + to="Access.groupv2", + ), + ), + ( + "requested_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="membership_requester", + to="Access.user", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="membership_user", + to="Access.user", + ), + ), + ], + ), + migrations.AddField( + model_name="groupv2", + name="approver", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="group_approver", + to="Access.user", + ), + ), + migrations.AddField( + model_name="groupv2", + name="requester", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="group_requester", + to="Access.user", + ), + ), + migrations.CreateModel( + name="GroupAccessMapping", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("request_id", models.CharField(max_length=255, unique=True)), + ("requested_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("request_reason", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("Pending", "pending"), + ("SecondaryPending", "secondarypending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Revoked", "revoked"), + ("Inactive", "inactive"), + ], + default="Pending", + max_length=100, + ), + ), + ("decline_reason", models.TextField(blank=True, null=True)), + ( + "access", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="Access.accessv2", + ), + ), + ( + "approver_1", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="g_approver_1", + to="Access.user", + ), + ), + ( + "approver_2", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="g_approver_2", + to="Access.user", + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="Access.groupv2" + ), + ), + ( + "requested_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="g_requester", + to="Access.user", + ), + ), + ( + "revoker", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="group_access_revoker", + to="Access.user", + ), + ), + ], + ), + ] diff --git a/Access/migrations/0002_delete_gitacces_remove_user_is_manager_and_more.py b/Access/migrations/0002_delete_gitacces_remove_user_is_manager_and_more.py new file mode 100644 index 00000000..2f6ac423 --- /dev/null +++ b/Access/migrations/0002_delete_gitacces_remove_user_is_manager_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.3 on 2022-12-26 08:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("Access", "0001_initial"), + ] + + operations = [ + migrations.DeleteModel( + name="gitAcces", + ), + migrations.RemoveField( + model_name="user", + name="is_manager", + ), + migrations.RemoveField( + model_name="user", + name="is_ops", + ), + ] diff --git a/Access/migrations/0003_user_is_manager_user_is_ops.py b/Access/migrations/0003_user_is_manager_user_is_ops.py new file mode 100644 index 00000000..31647fd7 --- /dev/null +++ b/Access/migrations/0003_user_is_manager_user_is_ops.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.3 on 2022-12-26 12:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("Access", "0002_delete_gitacces_remove_user_is_manager_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_manager", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="is_ops", + field=models.BooleanField(default=False), + ), + ] diff --git a/Access/migrations/__init__.py b/Access/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Access/models.py b/Access/models.py new file mode 100644 index 00000000..128c3366 --- /dev/null +++ b/Access/models.py @@ -0,0 +1,426 @@ +from BrowserStackAutomation.settings import USER_STATUS_CHOICES +from django.contrib.auth.models import User as user +from django.db import models + + +class Permission(models.Model): + """ + Permission to perform actions on enigma + """ + + label = models.CharField(max_length=255, null=False, blank=False, unique=True) + + def __str__(self): + return "%s" % (self.label) + + +class Role(models.Model): + """ + User role to attach permissions to perform actions on enigma; one user can have multiple roles + Role is a group of permissions which can be associated with a group of users + """ + + label = models.CharField(max_length=255, null=False, blank=False, unique=True) + permission = models.ManyToManyField(Permission) + + def __str__(self): + return "%s" % (self.label) + + +class SshPublicKey(models.Model): + """ + SSH Public keys for users + """ + + key = models.TextField(null=False, blank=False) + + STATUS_CHOICES = (("Active", "active"), ("Revoked", "revoked")) + status = models.CharField( + max_length=100, + null=False, + blank=False, + choices=STATUS_CHOICES, + default="Active", + ) + + def __str__(self): + return str(self.key) + + +# Create your models here. +class User(models.Model): + """ + Represents an user belonging to the organistaion + """ + + user = models.OneToOneField( + user, null=False, blank=False, on_delete=models.CASCADE, related_name="user" + ) + gitusername = models.CharField(max_length=255, null=True, blank=True) + name = models.CharField(max_length=255, null=True, blank=False) + + email = models.EmailField(null=True, blank=False) + phone = models.IntegerField(null=True, blank=True) + + is_bot = models.BooleanField(null=False, blank=False, default=False) + BOT_TYPES = ( + ("None", "none"), + ("Github", "github"), + ) + bot_type = models.CharField( + max_length=100, null=False, blank=False, choices=BOT_TYPES, default="None" + ) + + alerts_enabled = models.BooleanField(null=False, blank=False, default=False) + + is_manager = models.BooleanField(null=False, blank=False, default=False) + is_ops = models.BooleanField(null=False, blank=False, default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # ssh_pub_key will be deprecated. Use ssh_public_key field + ssh_pub_key = models.TextField(null=True, blank=True) + ssh_public_key = models.ForeignKey( + "SshPublicKey", + related_name="user", + blank=True, + null=True, + on_delete=models.PROTECT, + ) + + avatar = models.TextField(null=True, blank=True) + + state = models.CharField( + max_length=255, null=False, blank=False, choices=USER_STATUS_CHOICES, default=1 + ) + role = models.ManyToManyField(Role, blank=True) + + offbaord_date = models.DateTimeField(null=True, blank=True) + revoker = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="user_revoker", + on_delete=models.PROTECT, + ) + + @property + def permissions(self): + user_roles = self.role.all() + user_permissions = [ + permission for role in user_roles for permission in role.permission.all() + ] + return user_permissions + + def has_permission(self, permission_label): + all_permission_labels = [permission.label for permission in self.permissions] + return permission_label in all_permission_labels + + def current_state(self): + return dict(USER_STATUS_CHOICES).get(self.state) + + def change_state(self, final_state): + user_states = dict(USER_STATUS_CHOICES) + state_key = self.state + for key in user_states: + if user_states[key] == final_state: + state_key = key + self.state = state_key + self.save() + + def __str__(self): + return "%s" % (self.user) + + +class MembershipV2(models.Model): + """ + Membership of user in a GroupV2 + """ + + membership_id = models.CharField( + max_length=255, null=False, blank=False, unique=True + ) + + user = models.ForeignKey( + "User", + null=False, + blank=False, + related_name="membership_user", + on_delete=models.PROTECT, + ) + group = models.ForeignKey( + "GroupV2", + null=False, + blank=False, + related_name="membership_group", + on_delete=models.PROTECT, + ) + is_owner = models.BooleanField(null=False, blank=False, default=False) + + requested_by = models.ForeignKey( + User, + null=False, + blank=False, + related_name="membership_requester", + on_delete=models.PROTECT, + ) + requested_on = models.DateTimeField(auto_now_add=True) + updated_on = models.DateTimeField(auto_now=True) + + STATUS = ( + ("Pending", "pending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Revoked", "revoked"), + ) + status = models.CharField( + max_length=255, null=False, blank=False, choices=STATUS, default="Pending" + ) + + reason = models.TextField(null=True, blank=True) + + approver = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="membership_approver", + on_delete=models.PROTECT, + ) + decline_reason = models.TextField(null=True, blank=True) + + def __str__(self): + return self.group.name + "-" + self.user.email + "-" + self.status + + +class GroupV2(models.Model): + """ + Model for Enigma Groups redefined. + """ + + group_id = models.CharField(max_length=255, null=False, blank=False, unique=True) + requested_on = models.DateTimeField(auto_now_add=True) + updated_on = models.DateTimeField(auto_now=True) + + name = models.CharField(max_length=128, null=False, blank=False) + description = models.TextField(null=False, blank=False) + + requester = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="group_requester", + on_delete=models.PROTECT, + ) + + STATUS = ( + ("Pending", "pending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Deprecated", "deprecated"), + ) + status = models.CharField( + max_length=255, null=False, blank=False, choices=STATUS, default="Pending" + ) + + approver = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="group_approver", + on_delete=models.PROTECT, + ) + decline_reason = models.TextField(null=True, blank=True) + needsAccessApprove = models.BooleanField(null=False, blank=False, default=True) + + def __str__(self): + return self.name + +class UserAccessMapping(models.Model): + """ + Model to map access to user. Requests are broken down + into mappings which are sent for approval. + """ + + request_id = models.CharField(max_length=255, null=False, blank=False, unique=True) + + requested_on = models.DateTimeField(auto_now_add=True) + approved_on = models.DateTimeField(null=True, blank=True) + updated_on = models.DateTimeField(auto_now=True) + + user = models.ForeignKey("User", null=False, blank=False, on_delete=models.PROTECT) + + request_reason = models.TextField(null=False, blank=False) + + approver_1 = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="approver_1", + on_delete=models.PROTECT, + ) + approver_2 = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="approver_2", + on_delete=models.PROTECT, + ) + + access = models.ForeignKey( + "AccessV2", null=False, blank=False, on_delete=models.PROTECT + ) + + STATUS_CHOICES = ( + ("Pending", "pending"), + ("SecondaryPending", "secondarypending"), + ("Processing", "processing"), + ("Approved", "approved"), + ("GrantFailed", "grantfailed"), + ("Declined", "declined"), + ("Offboarding", "offboarding"), + ("ProcessingRevoke", "processingrevoke"), + ("RevokeFailed", "revokefailed"), + ("Revoked", "revoked"), + ) + status = models.CharField( + max_length=100, + null=False, + blank=False, + choices=STATUS_CHOICES, + default="Pending", + ) + + decline_reason = models.TextField(null=True, blank=True) + + TYPE_CHOICES = (("Individual", "individual"), ("Group", "group")) + access_type = models.CharField( + max_length=255, + null=False, + blank=False, + choices=TYPE_CHOICES, + default="Individual", + ) + revoker = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="user_access_revoker", + on_delete=models.PROTECT, + ) + + def __str__(self): + return self.request_id + + # Wrote the override version of save method in order to update the + # "approved_on" field whenever the request is marked "Approved" + def save(self, *args, **kwargs): + super(UserAccessMapping, self).save(*args, **kwargs) + # Consider only the first cycle of approval + if self.status.lower() == "approved" and self.approved_on in [None, ""]: + self.approved_on = self.updated_on + super(UserAccessMapping, self).save(*args, **kwargs) + + +class GroupAccessMapping(models.Model): + """ + Model to map access to group. Requests are broken down + into mappings which are sent for approval. + """ + + request_id = models.CharField(max_length=255, null=False, blank=False, unique=True) + + requested_on = models.DateTimeField(auto_now_add=True) + updated_on = models.DateTimeField(auto_now=True) + + group = models.ForeignKey( + "GroupV2", null=False, blank=False, on_delete=models.PROTECT + ) + + requested_by = models.ForeignKey( + "User", + null=True, + blank=False, + related_name="g_requester", + on_delete=models.PROTECT, + ) + + request_reason = models.TextField(null=False, blank=False) + + approver_1 = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="g_approver_1", + on_delete=models.PROTECT, + ) + approver_2 = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="g_approver_2", + on_delete=models.PROTECT, + ) + + access = models.ForeignKey( + "AccessV2", null=False, blank=False, on_delete=models.PROTECT + ) + + STATUS_CHOICES = ( + ("Pending", "pending"), + ("SecondaryPending", "secondarypending"), + ("Approved", "approved"), + ("Declined", "declined"), + ("Revoked", "revoked"), + ("Inactive", "inactive"), + ) + status = models.CharField( + max_length=100, + null=False, + blank=False, + choices=STATUS_CHOICES, + default="Pending", + ) + + decline_reason = models.TextField(null=True, blank=True) + + revoker = models.ForeignKey( + "User", + null=True, + blank=True, + related_name="group_access_revoker", + on_delete=models.PROTECT, + ) + + def __str__(self): + return self.request_id + + +class AccessV2(models.Model): + access_tag = models.CharField(max_length=255) + access_label = models.JSONField(default=dict) + is_auto_approved = models.BooleanField(null=False, default=False) + + def __str__(self): + try: + if self.access_tag == "aws": + label = self.access_label["data"] + return "{}: Team- {} | Access: {} | Level: {} | Service: {} | Resource: {}".format( + self.access_tag, + label["team"], + label["awsAccessType"], + label["levelAccessType"], + label["awsService"], + label["awsResource"], + ) + if self.access_tag == "other": + return self.access_tag + " - " + self.access_label["request"] + details_arr = [] + for data in list(self.access_label.values()): + try: + details_arr.append(data.decode("utf-8")) + except Exception: + details_arr.append(data) + return self.access_tag + " - " + ", ".join(details_arr) + except Exception: + return self.access_tag diff --git a/Access/tests.py b/Access/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/Access/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/Access/tests/test_access_helpers.py b/Access/tests/test_access_helpers.py new file mode 100644 index 00000000..2ef9760c --- /dev/null +++ b/Access/tests/test_access_helpers.py @@ -0,0 +1,58 @@ +import pytest +from Access import helpers +from Access.helpers import getAvailableAccessModules, getAccessModules + + +class MockAccessModule: + def __init__(self, name=""): + self.name = name + self.available = True + + +@pytest.mark.parametrize( + "testName, available_accesses, expectedModuleList", + [ + ( + "available_accesses has values", + [MockAccessModule(name="name1"), MockAccessModule(name="name2")], + [MockAccessModule(name="name1"), MockAccessModule(name="name2")], + ), + ( + "available_accesses has no values", + [], + [MockAccessModule(name="name3"), MockAccessModule(name="name4")], + ), + ], +) +def test_getAvailableAccessModules( + mocker, testName, available_accesses, expectedModuleList +): + mocker.patch("Access.helpers.getAccessModules", return_value=expectedModuleList) + + helpers.available_accesses = available_accesses + modules = getAvailableAccessModules() + assert len(modules) == len(expectedModuleList) + for i in range(len(modules)): + assert modules[i].name == expectedModuleList[i].name + + +@pytest.mark.parametrize( + "testName, cached_accesses, expectedModuleList", + [ + ( + "cached_accesses has values", + [MockAccessModule(name="name1"), MockAccessModule(name="name2")], + [MockAccessModule(name="name1"), MockAccessModule(name="name2")], + ), + ], +) +def test_getAccessModules(mocker, testName, cached_accesses, expectedModuleList): + mocker.patch( + "glob.glob", return_value=["dir1", "dir2"] + ["base_somedir", "__pycache__"] + ) + mocker.patch("os.path.isfile", return_value=False) + helpers.cached_accesses = cached_accesses + modules = getAccessModules() + assert len(modules) == len(expectedModuleList) + for i in range(len(modules)): + assert modules[i].name == expectedModuleList[i].name diff --git a/Access/tests/test_access_views_helper.py b/Access/tests/test_access_views_helper.py new file mode 100644 index 00000000..451fde8b --- /dev/null +++ b/Access/tests/test_access_views_helper.py @@ -0,0 +1,217 @@ +from Access.views_helper import generateUserMappings, executeGroupAccess +from Access import models +import pytest +from Access import views_helper +from bootprocess import general +import logging + + +class MockAuthUser: + def __init__(self, username="", user=""): + self.user = user + self.username = username + + +class MockRequest: + def __init__(self, username=""): + self.user = MockAuthUser(username) + + +def test_generateUserMappings(mocker): + class MemberShipObj: + def __init__(self, membership_id="", reason=""): + self.membership_id = membership_id + self.reason = reason + + class AuthUser: + def __init__(self, username): + self.username = username + self.user = username + + class User: + def __init__(self, user=""): + self.user = AuthUser(username="") + + class MockAccess: + def __init__(self): + self.access_tag = "" + + class MockGroupAccessMapping: + def __init__(self, access="", approver_1="", approver_2="", request_reason=""): + self.access = access + self.approver_1 = approver_1 + self.approver_2 = approver_2 + self.request_reason = request_reason + self.access = MockAccess() + + class MockUserAccessMapping: + def __init__( + self, + request_id="", + user="", + access="", + approver_1="", + approver_2="", + request_reason="", + access_type="", + status="", + ): + self.request_id = request_id + self.user = user + self.access = access + self.approver_1 = approver_1 + self.approver_2 = approver_2 + self.request_reason = request_reason + self.access_type = access_type + self.status = status + + def __len__(self): + return 1 + + def values_list(*args, **kwargs): + return [""] + + mock_objects = mocker.MagicMock(name="objects") + mock_objects.values_list = values_list + mocker.patch( + "Access.models.GroupAccessMapping.objects.filter", + return_value=[MockGroupAccessMapping()], + ) + groupAccessMappingSpy = mocker.spy(models.GroupAccessMapping.objects, "filter") + + mocker.patch( + "Access.models.UserAccessMapping.objects.filter", + return_value=mock_objects, + side_effect=mocker.patch( + "Access.models.UserAccessMapping.objects.filter", return_value=mock_objects + ), + ) + userAccessMappingFilterSpy = mocker.spy(models.UserAccessMapping.objects, "filter") + + mocker.patch( + "Access.models.UserAccessMapping.objects.create", + return_value=MockUserAccessMapping( + request_id="request_id1", + user="user1", + access="access2", + approver_1="approver_1", + approver_2="approver_2", + request_reason="reason123", + access_type="access_type", + status="status1", + ), + ) + userAccessMappingCreateSpy = mocker.spy(models.UserAccessMapping.objects, "create") + + usermappingList = generateUserMappings( + User(user="username1"), + MemberShipObj(), + MemberShipObj(membership_id="1", reason="reason"), + ) + + assert usermappingList[0].request_id == "request_id1" + assert groupAccessMappingSpy.call_count == 1 + assert userAccessMappingFilterSpy.call_count == 2 + assert userAccessMappingCreateSpy.call_count == 1 + + +@pytest.mark.parametrize( + "testName, userstate, requestid, expectedStatus, expected_decline_reason", + [ + ( + "User is Active", + "active", + "other", + "Declined", + "Auto decline for 'Other Access'. Please replace this with correct access.", + ), + ("User is Active", "inactive", "", "Declined", "User is not active"), + ], +) +def test_executeGroupAccess( + mocker, testName, userstate, requestid, expectedStatus, expected_decline_reason +): + userMock = mocker.MagicMock() + userMock.username = "username" + userMock.current_state.return_value = userstate + + mappingObj = mocker.MagicMock() + mappingObj.access.access_tag = "tagname" + mappingObj.user = userMock + mappingObj.approver_1.user = userMock + mappingObj.request_id = requestid + executeGroupAccess([mappingObj]) + assert mappingObj.status == expectedStatus + assert mappingObj.decline_reason == expected_decline_reason + + +@pytest.mark.parametrize( + """testName, userstate, requestid,user_state, + approvalResponse ,expectedStatus, emailSesCallCount,""", + [ + ( + "User is Active, request is not other, user.state not 1", + "active", + "reqid", + "2", + None, + "Declined", + 0, + ), + ( + "User is Active, request is not other, user.state is 1, Approval Returns true", + "active", + "reqid", + "1", + True, + "Approved", + 0, + ), + ( + "User is Active, request is not other, user.state is 1, Approval Fails", + "active", + "reqid", + "1", + [False, "Failure Message"], + "GrantFailed", + 1, + ), + ], +) +def test_executeGroupAccess_run_access_grant( + mocker, + testName, + userstate, + approvalResponse, + requestid, + user_state, + expectedStatus, + emailSesCallCount, +): + views_helper.logger = logging.getLogger(__name__) + mockAccessModule = mocker.MagicMock() + mockAccessModule.tag.return_value = "tagname" + mockAccessModule.approve.return_value = approvalResponse + mockAccessModule.access_mark_revoke_permission.return_value = "destination" + + userMock = mocker.MagicMock() + userMock.username = "username" + userMock.current_state.return_value = userstate + userMock.state = user_state + + mappingObj = mocker.MagicMock() + mappingObj.access.access_tag = "tagname" + mappingObj.user = userMock + mappingObj.approver_1.user = userMock + mappingObj.request_id = requestid + mappingObj.status = "" + mappingObj.decline_reason = "" + + mocker.patch("bootprocess.general.emailSES", return_value="") + emailSES_Spy = mocker.spy(general, "emailSES") + + views_helper.all_access_modules = [mockAccessModule] + + executeGroupAccess([mappingObj]) + assert mappingObj.status == expectedStatus + assert emailSES_Spy.call_count == emailSesCallCount diff --git a/Access/views.py b/Access/views.py new file mode 100644 index 00000000..55950503 --- /dev/null +++ b/Access/views.py @@ -0,0 +1,61 @@ +from django.contrib.auth.decorators import login_required +import logging +from . import helpers as helper +from .decorators import user_admin_or_ops, authentication_classes, user_with_permission +from rest_framework.authentication import TokenAuthentication, BasicAuthentication +from rest_framework.decorators import api_view + +logger = logging.getLogger(__name__) + +# Create your views here. +all_access_modules = helper.getAvailableAccessModules() + + +@login_required +def showAccessHistory(request): + return False + + +@login_required +@user_admin_or_ops +def pendingFailure(request): + return False + + +@login_required +@user_admin_or_ops +def pendingRevoke(request): + return False + + +@login_required +def updateUserInfo(request): + return False + + +@login_required +def createNewGroup(request): + return False + + +@api_view(["GET"]) +@login_required +@user_with_permission(["VIEW_USER_ACCESS_LIST"]) +@authentication_classes((TokenAuthentication, BasicAuthentication)) +def allUserAccessList(request, load_ui=True): + return False + + +@login_required +def allUsersList(request): + return False + + +@login_required +def requestAccess(request): + return False + + +@login_required +def groupRequestAccess(request): + return False diff --git a/Access/views_helper.py b/Access/views_helper.py new file mode 100644 index 00000000..a4b7e756 --- /dev/null +++ b/Access/views_helper.py @@ -0,0 +1,202 @@ +from .models import UserAccessMapping, GroupAccessMapping +import datetime +import traceback +import logging +from . import helpers as helper +from bootprocess import general + +logger = logging.getLogger(__name__) +all_access_modules = helper.getAvailableAccessModules() + + +def generateUserMappings(user, group, membershipObj): + groupMappings = GroupAccessMapping.objects.filter(group=group, status="Approved") + + userMappingsList = [] + for groupMapping in groupMappings: + access = groupMapping.access + approver_1 = groupMapping.approver_1 + approver_2 = groupMapping.approver_2 + membership_id = membershipObj.membership_id + base_datetime_prefix = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") + reason = ( + "Added to group for request " + + membership_id + + " - " + + membershipObj.reason + + " - " + + groupMapping.request_reason + ) + request_id = ( + user.user.username + "-" + access.access_tag + "-" + base_datetime_prefix + ) + similar_id_mappings = list( + UserAccessMapping.objects.filter( + request_id__icontains=request_id + ).values_list("request_id", flat=True) + ) + idx = 0 + while request_id + "_" + str(idx) in similar_id_mappings: + idx += 1 + request_id = request_id + "_" + str(idx) + if not len( + UserAccessMapping.objects.filter( + user=user, access=access, status="Approved" + ) + ): + userMappingObj = UserAccessMapping.objects.create( + request_id=request_id, + user=user, + access=access, + approver_1=approver_1, + approver_2=approver_2, + request_reason=reason, + access_type="Group", + status="Processing", + ) + userMappingsList.append(userMappingObj) + return userMappingsList + + +def executeGroupAccess(userMappingsList): + for mappingObj in userMappingsList: + + accessType = mappingObj.access.access_tag + user = mappingObj.user + approver = mappingObj.approver_1.user.username + if user.current_state() == "active": + if "other" in mappingObj.request_id: + decline_group_other_access(mappingObj) + else: + run_access_grant( + mappingObj.request_id, mappingObj, accessType, user, approver + ) + logger.debug( + "Successful group access grant for " + mappingObj.request_id + ) + else: + mappingObj.status = "Declined" + mappingObj.decline_reason = "User is not active" + mappingObj.save() + logger.debug( + "Skipping group access grant for user " + + user.user.username + + " as user is not active" + ) + + +def decline_group_other_access(access_mapping): + user = access_mapping.user + access_mapping.status = "Declined" + access_mapping.decline_reason = ( + "Auto decline for 'Other Access'. Please replace this with correct access." + ) + access_mapping.save() + logger.debug( + "Skipping group access grant for user " + + user.user.username + + " for request_id " + + access_mapping.request_id + + " as it is 'Other Access'" + ) + + +def run_access_grant(requestId, requestObject, accessType, user, approver): + message = "" + if not requestObject.user.state == "1": + requestObject.status = "Declined" + requestObject.save() + logger.debug( + { + "requestId": requestId, + "status": "Declined", + "by": approver, + "response": message, + } + ) + return False + for each_access_module in all_access_modules: + if accessType == each_access_module.tag(): + try: + response = each_access_module.approve( + user, + [requestObject.access.access_label], + approver, + requestId, + is_group=False, + ) + if type(response) is bool: + approve_success = response + else: + approve_success = response[0] + message = str(response[1]) + except Exception: + logger.exception( + "Error while running approval module: " + str(traceback.format_exc()) + ) + approve_success = False + message = str(traceback.format_exc()) + if approve_success: + requestObject.status = "Approved" + requestObject.save() + logger.debug( + { + "requestId": requestId, + "status": "Approved", + "by": approver, + "response": message, + } + ) + else: + requestObject.status = "GrantFailed" + requestObject.save() + logger.debug( + { + "requestId": requestId, + "status": "GrantFailed", + "by": approver, + "response": message, + } + ) + try: + destination = [ + each_access_module.access_mark_revoke_permission(accessType) + ] + subject = str("Access Grant Failed - ") + accessType.upper() + body = ( + "Request by " + + user.email + + " having Request ID = " + + requestId + + " is GrantFailed. Please debug and rerun the grant.
" + ) + body = body + "Failure Reason - " + message + body = ( + body + + "

View all failed grants" + ) + logger.debug( + "Sending Grant Failed email - " + + str(destination) + + " - " + + subject + + " - " + + body + ) + general.emailSES(destination, subject, body) + except Exception: + logger.debug( + "Grant Failed - Error while sending email - " + + requestId + + "-" + + str(str(traceback.format_exc())) + ) + + # For generic modules, approve method will send an email on "Access granted", + # additional email of "Access approved" is not needed + return True + return False diff --git a/BrowserStackAutomation/settings.py b/BrowserStackAutomation/settings.py index 8c6a4e54..20651043 100644 --- a/BrowserStackAutomation/settings.py +++ b/BrowserStackAutomation/settings.py @@ -24,7 +24,7 @@ # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "" +SECRET_KEY = "abc" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -42,8 +42,8 @@ "django.contrib.messages", "django.contrib.staticfiles", "bootprocess.apps.BootprocessConfig", - 'social_django', - 'Access', + "social_django", + "Access", ] MIDDLEWARE = [ @@ -54,41 +54,38 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - 'social_django.middleware.SocialAuthExceptionMiddleware', + "social_django.middleware.SocialAuthExceptionMiddleware", ] AUTHENTICATION_BACKENDS = ( - 'social_core.backends.google.GoogleOAuth2', - 'django.contrib.auth.backends.ModelBackend', + "social_core.backends.google.GoogleOAuth2", + "django.contrib.auth.backends.ModelBackend", ) SOCIAL_AUTH_PIPELINE = ( - 'social_core.pipeline.social_auth.social_details', - 'social_core.pipeline.social_auth.social_uid', - 'social_core.pipeline.social_auth.auth_allowed', - 'social_core.pipeline.social_auth.social_user', - 'social_core.pipeline.user.get_username', - 'social_core.pipeline.user.create_user', - 'social_core.pipeline.social_auth.associate_user', - 'social_core.pipeline.social_auth.load_extra_data', - 'social_core.pipeline.user.user_details', - 'social_core.pipeline.debug.debug', + "social_core.pipeline.social_auth.social_details", + "social_core.pipeline.social_auth.social_uid", + "social_core.pipeline.social_auth.auth_allowed", + "social_core.pipeline.social_auth.social_user", + "social_core.pipeline.user.get_username", + "social_core.pipeline.user.create_user", + "social_core.pipeline.social_auth.associate_user", + "social_core.pipeline.social_auth.load_extra_data", + "social_core.pipeline.user.user_details", + "social_core.pipeline.debug.debug", ) SOCIAL_AUTH_DISCONNECT_PIPELINE = ( -# Verifies that the social association can be disconnected from the current -# user (ensure that the user login mechanism is not compromised by this -# disconnection). -#'social.pipeline.disconnect.allowed_to_disconnect', - -# Collects the social associations to disconnect. -'social_core.pipeline.disconnect.get_entries', - -# Revoke any access_token when possible. -'social_core.pipeline.disconnect.revoke_tokens', - -# Removes the social associations. -'social_core.pipeline.disconnect.disconnect', + # Verifies that the social association can be disconnected from the current + # user (ensure that the user login mechanism is not compromised by this + # disconnection). + # 'social.pipeline.disconnect.allowed_to_disconnect', + # Collects the social associations to disconnect. + "social_core.pipeline.disconnect.get_entries", + # Revoke any access_token when possible. + "social_core.pipeline.disconnect.revoke_tokens", + # Removes the social associations. + "social_core.pipeline.disconnect.disconnect", ) ROOT_URLCONF = "BrowserStackAutomation.urls" @@ -160,7 +157,7 @@ # https://docs.djangoproject.com/en/4.1/howto/static-files/ STATIC_URL = "static/" -STATIC_ROOT = os.path.join(BASE_DIR, 'public/') +STATIC_ROOT = os.path.join(BASE_DIR, "public/") STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static/"), ] @@ -170,18 +167,20 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -with open('config.json') as data_file: +with open("config.json") as data_file: data = json.load(data_file) -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = data['googleapi']['SOCIAL_AUTH_GOOGLE_OAUTH2_KEY'] -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET= data['googleapi']['SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET'] -SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = data['googleapi']['SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS'] +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = data["googleapi"]["SOCIAL_AUTH_GOOGLE_OAUTH2_KEY"] +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = data["googleapi"]["SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"] +SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = data["googleapi"][ + "SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS" +] USER_STATUS_CHOICES = [ - ('1', 'active'), - ('2', 'offboarding'), - ('3', 'offboarded'), + ("1", "active"), + ("2", "offboarding"), + ("3", "offboarded"), ] -DEFAULT_ACCESS_GROUP = 'default_access_group' \ No newline at end of file +DEFAULT_ACCESS_GROUP = "default_access_group" diff --git a/BrowserStackAutomation/urls.py b/BrowserStackAutomation/urls.py index 1a86fb12..856449d3 100644 --- a/BrowserStackAutomation/urls.py +++ b/BrowserStackAutomation/urls.py @@ -17,23 +17,31 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import re_path, include -from Access.views import showAccessHistory, pendingFailure, pendingRevoke, updateUserInfo, createNewGroup, allUserAccessList, allUsersList, requestAccess, groupRequestAccess -from django.conf.urls.static import static +from Access.views import ( + showAccessHistory, + pendingFailure, + pendingRevoke, + updateUserInfo, + createNewGroup, + allUserAccessList, + allUsersList, + requestAccess, + groupRequestAccess, +) urlpatterns = [ - re_path(r'^admin/', admin.site.urls), - re_path(r'^$',dashboard, name='dashboard'), - re_path(r'^login/$', auth_views.LoginView.as_view(), name='login'), - re_path(r'^logout/$', logout_view, name='logout'), - re_path(r'^oauth/', include('social_django.urls', namespace='social')), - - re_path(r'^access/showAccessHistory$', showAccessHistory, name='showAccessHistory'), - re_path(r'^resolve/pendingFailure',pendingFailure, name='pendingFailure'), - re_path(r'^resolve/pendingRevoke',pendingRevoke, name='pendingRevoke'), - re_path(r'^user/updateUserInfo/',updateUserInfo,name='updateUserInfo'), - re_path(r'^group/create$', createNewGroup, name='createNewGroup'), - re_path(r'^access/userAccesses$', allUserAccessList, name='allUserAccessList'), - re_path(r'^access/usersList$', allUsersList, name='allUsersList'), - re_path(r'^access/requestAccess$', requestAccess, name='requestAccess'), - re_path(r'^group/requestAccess$', groupRequestAccess, name='groupRequestAccess'), + re_path(r"^admin/", admin.site.urls), + re_path(r"^$", dashboard, name="dashboard"), + re_path(r"^login/$", auth_views.LoginView.as_view(), name="login"), + re_path(r"^logout/$", logout_view, name="logout"), + re_path(r"^oauth/", include("social_django.urls", namespace="social")), + re_path(r"^access/showAccessHistory$", showAccessHistory, name="showAccessHistory"), + re_path(r"^resolve/pendingFailure", pendingFailure, name="pendingFailure"), + re_path(r"^resolve/pendingRevoke", pendingRevoke, name="pendingRevoke"), + re_path(r"^user/updateUserInfo/", updateUserInfo, name="updateUserInfo"), + re_path(r"^group/create$", createNewGroup, name="createNewGroup"), + re_path(r"^access/userAccesses$", allUserAccessList, name="allUserAccessList"), + re_path(r"^access/usersList$", allUsersList, name="allUsersList"), + re_path(r"^access/requestAccess$", requestAccess, name="requestAccess"), + re_path(r"^group/requestAccess$", groupRequestAccess, name="groupRequestAccess"), ] diff --git a/bootprocess/admin.py b/bootprocess/admin.py index 260fa08f..050fc7be 100644 --- a/bootprocess/admin.py +++ b/bootprocess/admin.py @@ -2,4 +2,4 @@ from Access.models import User -admin.site.register(User) \ No newline at end of file +admin.site.register(User) diff --git a/bootprocess/general.py b/bootprocess/general.py index d7fe9d84..4ad3303e 100644 --- a/bootprocess/general.py +++ b/bootprocess/general.py @@ -1,6 +1,3 @@ -from os import environ -import boto3 - -def emailSES(destination, subject,body): - print("Email Sent!!!") +def emailSES(destination, subject, body): + print("Email Sent!!!") return True diff --git a/bootprocess/models.py b/bootprocess/models.py index 71a83623..6b202199 100644 --- a/bootprocess/models.py +++ b/bootprocess/models.py @@ -1,3 +1 @@ -from django.db import models - # Create your models here. diff --git a/bootprocess/tests.py b/bootprocess/tests.py index 7ce503c2..a39b155a 100644 --- a/bootprocess/tests.py +++ b/bootprocess/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - # Create your tests here. diff --git a/bootprocess/tests/test_views_helper.py b/bootprocess/tests/test_views_helper.py index 21d8de67..41ec9d10 100644 --- a/bootprocess/tests/test_views_helper.py +++ b/bootprocess/tests/test_views_helper.py @@ -1,54 +1,61 @@ import pytest from bootprocess import views_helper -from django.http import HttpRequest, QueryDict from Access import models -import Access.helpers as helpers -from Access.models import User as access_user, MembershipV2 -import json import threading from BrowserStackAutomation.settings import DEFAULT_ACCESS_GROUP class MockAuthUser: - def __init__(self,username="", user=""): + def __init__(self, username="", user=""): self.user = user self.username = username + class MockRequest: - def __init__(self, username = ""): - self.user=MockAuthUser(username) - - -@pytest.mark.parametrize("testName, userIsInDefaultAccessGroup, gitcount, dashboardCount, sshMachineCount, groupCount",[ - #user is not part of default group and has respective count of git repo, dashboard, ssh machines and group accesses - # ("UserInDefaultGroup", True, 10, 20, 30, 40), - ("UserInDefaultGroup", False, 10, 20, 30, 40), - -]) -def test_getDashboardData(monkeypatch, testName, userIsInDefaultAccessGroup, gitcount, dashboardCount, sshMachineCount, groupCount): - + def __init__(self, username=""): + self.user = MockAuthUser(username) + + +@pytest.mark.parametrize( + "testName, userIsInDefaultAccessGroup, gitcount, dashboardCount, sshMachineCount, groupCount", + [ + # user is not part of default group and has respective count of git repo, + # dashboard, ssh machines and group accesses + ("UserInDefaultGroup", True, 10, 20, 30, 40), + ("UserInDefaultGroup", False, 10, 20, 30, 40), + ], +) +def test_getDashboardData( + monkeypatch, + testName, + userIsInDefaultAccessGroup, + gitcount, + dashboardCount, + sshMachineCount, + groupCount, +): class MockUserModelobj: - def __init__(self,user="", gitusername="", name = ""): + def __init__(self, user="", gitusername="", name=""): self.user = user self.gitusername = gitusername self.name = name - def get (self,user__username=""): + def get(self, user__username=""): self.user = user__username return MockUserModelobj(user="username", gitusername="username") class MockGitAccessModelobj: - def __init__(self, requestInfo = {"":""}): + def __init__(self, requestInfo={"": ""}): self.user = "" self.requestInfo = requestInfo - def filter (self,requester="",status=""): + def filter(self, requester="", status=""): if status == "Approved": return [MockGitAccessModelobj()] return MockGitAccessModelobj() class MockGroupV2: - def filter (self,name = "", status = ""): + def filter(self, name="", status=""): if status == "Approved": return [MockGroupV2()] return MockGitAccessModelobj() @@ -61,70 +68,78 @@ def filter(self, user="", status="", access__access_tag=""): dashboard.append(i) return dashboard elif access__access_tag == "github_access": - gitRepo=[] + gitRepo = [] for i in range(gitcount): gitRepo.append(i) return gitRepo elif access__access_tag == "ssh": - ssh=[] + ssh = [] for i in range(sshMachineCount): ssh.append(i) return ssh else: - group=[] + group = [] for i in range(groupCount): group.append(i) return group + class Group: def __init__(self): - self.name= "" + self.name = "" class MockMembershipV2: - def __init__(self,is_owner = False, approver = "", status = "", user = ""): + def __init__(self, is_owner=False, approver="", status="", user=""): self.is_owner = is_owner self.approver = approver self.status = status self.user = user self.group = Group() - def filter (self,user = "", status = "", group = "", is_owner = False): + + def filter(self, user="", status="", group="", is_owner=False): if is_owner: return [MockMembershipV2()] return MockMembershipV2() - def only (self,filter): + + def only(self, filter): return MockMembershipV2() def create(*args, **kwargs): return MockMembershipV2() + def save(self): return "" + def __str__(self): - if userIsInDefaultAccessGroup : + if userIsInDefaultAccessGroup: return DEFAULT_ACCESS_GROUP else: return "" + def __len__(self): return groupCount + class MockThread: def start(self): return True def mockgenerateUserMappings(*args, **kwargs): return [] + views_helper.generateUserMappings = mockgenerateUserMappings + def mock_Thread(*args, **kwargs): - return MockThread() + return MockThread() monkeypatch.setattr(threading, "Thread", mock_Thread) models.MembershipV2.objects = MockMembershipV2() models.User.objects = MockUserModelobj() models.UserAccessMapping.objects = MockUserAccessMapping() - models.gitAcces.objects = MockGitAccessModelobj() models.GroupV2.objects = MockGroupV2() request = MockRequest(username="username1") context = views_helper.getDashboardData(request) - assert context["regions"] == ['eu-central-1'] + assert context["regions"] == ["eu-central-1"] assert context["gitCount"] == gitcount assert context["dashboardCount"] == dashboardCount assert context["sshMachineCount"] == sshMachineCount - assert context["groupCount"] == groupCount \ No newline at end of file + assert context["groupCount"] == groupCount diff --git a/bootprocess/views.py b/bootprocess/views.py index f0e62880..e8e25a82 100644 --- a/bootprocess/views.py +++ b/bootprocess/views.py @@ -2,26 +2,22 @@ from django.contrib.auth.decorators import login_required from django.shortcuts import render import logging -from Access.models import User as access_user, MembershipV2, GroupV2, gitAcces, UserAccessMapping -from BrowserStackAutomation.settings import DEFAULT_ACCESS_GROUP -from Access.views import executeGroupAccess, generateUserMappings -import threading -import json -import datetime from .views_helper import getDashboardData logger = logging.getLogger(__name__) # Create your views here. + + @login_required def logout_view(request): """Logout View runs when logout url call""" logout(request) logger.debug("User: {0} is logging out".format(request.user.username)) - return render(request,"registration/login.html") + return render(request, "registration/login.html") @login_required def dashboard(request): """Loaded dashboard""" context = getDashboardData(request) - return render(request,"BSOps/dashboard.html",context) \ No newline at end of file + return render(request, "BSOps/dashboard.html", context) diff --git a/bootprocess/views_helper.py b/bootprocess/views_helper.py index e92400cc..a3c1e5ec 100644 --- a/bootprocess/views_helper.py +++ b/bootprocess/views_helper.py @@ -1,5 +1,10 @@ import logging -from Access.models import User as access_user, MembershipV2, GroupV2, gitAcces, UserAccessMapping +from Access.models import ( + User as access_user, + MembershipV2, + GroupV2, + UserAccessMapping, +) from BrowserStackAutomation.settings import DEFAULT_ACCESS_GROUP from Access.views_helper import executeGroupAccess, generateUserMappings import threading @@ -8,34 +13,65 @@ logger = logging.getLogger(__name__) + def getDashboardData(request): - user=access_user.objects.get(user__username=request.user) + user = access_user.objects.get(user__username=request.user) # Add users to DEFAULT_ACCESS_GROUP - user_membership=str(MembershipV2.objects.filter(user=user).filter(status="Approved")) + user_membership = str( + MembershipV2.objects.filter(user=user).filter(status="Approved") + ) if DEFAULT_ACCESS_GROUP not in user_membership: - group_objects = GroupV2.objects.filter(name=DEFAULT_ACCESS_GROUP).filter(status='Approved') + group_objects = GroupV2.objects.filter(name=DEFAULT_ACCESS_GROUP).filter( + status="Approved" + ) if len(group_objects) > 0: group = group_objects[0] - group_members = MembershipV2.objects.filter(group=group).filter(status="Approved").only('user') - group_owner = [member for member in group_members.filter(is_owner = True)] - membership_id = user.name+'-'+str(group)+'-membership-'+datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") - member = MembershipV2.objects.create(group=group,user=user,reason='New joiner added to BrowserStack defaut access group.',membership_id=membership_id,requested_by=group_owner[0].user) + group_members = ( + MembershipV2.objects.filter(group=group) + .filter(status="Approved") + .only("user") + ) + group_owner = [member for member in group_members.filter(is_owner=True)] + membership_id = ( + user.name + + "-" + + str(group) + + "-membership-" + + datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") + ) + member = MembershipV2.objects.create( + group=group, + user=user, + reason="New joiner added to BrowserStack defaut access group.", + membership_id=membership_id, + requested_by=group_owner[0].user, + ) member.approver = group_owner[0].user - member.status = 'Approved' + member.status = "Approved" user_mappings_list = generateUserMappings(user, group, member) member.save() group_name = member.group.name - access_accept_thread = threading.Thread(target=executeGroupAccess, args=(request, group_name, user_mappings_list)) + access_accept_thread = threading.Thread( + target=executeGroupAccess, + args=(request, group_name, user_mappings_list), + ) access_accept_thread.start() - logger.debug("Process has been started for the Approval of request - " + membership_id + " - Approver=" + request.user.username) + logger.debug( + "Process has been started for the Approval of request - " + + membership_id + + " - Approver=" + + request.user.username + ) if not user.gitusername: - logger.debug("Redirecting User to Fill out git username and his public key for ssh access.") + logger.debug( + "Redirecting User to Fill out git username and his public key for ssh access." + ) # return redirect('updateUserInfo') - with open('instanceTypes.json') as data_file: + with open("instanceTypes.json") as data_file: data = json.load(data_file) ec2_regions = list(data.keys()) @@ -47,25 +83,31 @@ def getDashboardData(request): sshMachineCount = 0 groupCount = 0 - #TODO: Is this code even used? - gitRepos = gitAcces.objects.filter(requester=str(request.user)).filter(status='Approved') - for repos in gitRepos: - if 'selectedRepoList' in repos.requestInfo: - gitCount += len(repos.requestInfo['selectedRepoList']) - for re in repos.requestInfo['selectedRepoList']: - dataList.append({'name':re,'accessType':"GIT",'accessLevel':repos.requestInfo['gitAccessLevel'][0],'type':'Personal'}) - else: - gitCount += 1 - - dashboardCount = len(UserAccessMapping.objects.filter(user=request.user.user, status="Approved", access__access_tag="other")) - sshMachineCount = len(UserAccessMapping.objects.filter(user=request.user.user, status="Approved", access__access_tag="ssh")) - gitCount = len(UserAccessMapping.objects.filter(user=request.user.user, status="Approved", access__access_tag="github_access")) - groupCount = len(MembershipV2.objects.filter(user=request.user.user, status="Approved")) + dashboardCount = len( + UserAccessMapping.objects.filter( + user=request.user.user, status="Approved", access__access_tag="other" + ) + ) + sshMachineCount = len( + UserAccessMapping.objects.filter( + user=request.user.user, status="Approved", access__access_tag="ssh" + ) + ) + gitCount = len( + UserAccessMapping.objects.filter( + user=request.user.user, + status="Approved", + access__access_tag="github_access", + ) + ) + groupCount = len( + MembershipV2.objects.filter(user=request.user.user, status="Approved") + ) - context['regions'] = ec2_regions - context['gitCount'] = gitCount - context['dashboardCount'] = dashboardCount - context['sshMachineCount'] = sshMachineCount - context['groupCount'] = groupCount + context["regions"] = ec2_regions + context["gitCount"] = gitCount + context["dashboardCount"] = dashboardCount + context["sshMachineCount"] = sshMachineCount + context["groupCount"] = groupCount - return context \ No newline at end of file + return context diff --git a/instanceTypes.json b/instanceTypes.json new file mode 100644 index 00000000..7721b839 --- /dev/null +++ b/instanceTypes.json @@ -0,0 +1,38 @@ +{ + "eu-central-1": { + "instanceTypes": [ + { + "type": "t3.nano", + "dispName": "General -- CPU: 1 -- RAM: 0.5 -- $0.0067/hr" + }, + { + "type": "t3.micro", + "dispName": "General -- CPU: 1 -- RAM: 1 -- $0.0134/hr" + }, + { + "type": "t3.small", + "dispName": "General -- CPU: 1 -- RAM: 2 -- $0.0268/hr" + }, + { + "type": "t3.medium", + "dispName": "General -- CPU: 2 -- RAM: 4 -- $0.0536/hr" + }, + { + "type": "t3.large", + "dispName": "General -- CPU: 2 -- RAM: 8 -- $0.1072/hr" + }, + { + "type": "m5.large", + "dispName": "EBS-Optimized -- CPU: 2 -- RAM: 8 -- $0.1150/hr" + }, + { + "type": "c5.large", + "dispName": "Compute -- CPU: 2 -- RAM: 4 -- $0.0970/hr" + }, + { + "type": "c5.xlarge", + "dispName": "Compute -- CPU: 4 -- RAM: 8 -- $0.1940/hr" + } + ] + } +} \ No newline at end of file diff --git a/templates/BSOps/dashboard.html b/templates/BSOps/dashboard.html new file mode 100644 index 00000000..d78d005f --- /dev/null +++ b/templates/BSOps/dashboard.html @@ -0,0 +1,193 @@ +{% extends 'global_layout.html' %} +{% load static %} + +{% block content_body %} + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
Github +
+
{{gitCount}}
+
+
+
+
+
+
+
+
SSH +
+
{{sshMachineCount}}
+
+
+
+
+
+
+
+
Groups +
+
{{groupCount}}
+
+
+
+
+ +
+
+
+
+ + +
+
+ +

Group Requests 
(Click to Expand)


+
+
+ {% if groups %} +
+ +
+
+ +
+ {% endif %} +
+ +
+
+
+
+ +
+
+ +

Resource Creation Requests 
(Click to Expand)


+
+
+
+ +
+
+
+
+ +
+
+
+
+{% endblock %}