From 6f50527d824b97fc4fc002b9bfbed5361ab9d0a1 Mon Sep 17 00:00:00 2001 From: eric-intuitem <71850047+eric-intuitem@users.noreply.github.com> Date: Sat, 24 Feb 2024 20:04:47 +0100 Subject: [PATCH 1/9] Rationalize all object classes Make an inheritance patttern that is simpler and more logical Put IAM object in logical order --- backend/cal/migrations/0001_initial.py | 2 +- backend/core/base_models.py | 33 +++--- backend/core/migrations/0001_initial.py | 32 +++--- backend/core/migrations/0002_initial.py | 2 +- backend/core/models.py | 28 ++--- backend/iam/migrations/0001_initial.py | 35 ++++-- backend/iam/migrations/0002_role_builtin.py | 18 +++ backend/iam/models.py | 118 ++++++++++---------- 8 files changed, 149 insertions(+), 119 deletions(-) create mode 100644 backend/iam/migrations/0002_role_builtin.py diff --git a/backend/cal/migrations/0001_initial.py b/backend/cal/migrations/0001_initial.py index 3977804241..a852e947d5 100644 --- a/backend/cal/migrations/0001_initial.py +++ b/backend/cal/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-23 00:51 +# Generated by Django 5.0.2 on 2024-02-24 18:49 from django.db import migrations, models diff --git a/backend/core/base_models.py b/backend/core/base_models.py index c4df8022f6..024019966b 100644 --- a/backend/core/base_models.py +++ b/backend/core/base_models.py @@ -7,20 +7,6 @@ from ciso_assistant import settings -class NameDescriptionMixin(models.Model): - """ - Mixin for models that have a name and a description. - """ - - name = models.CharField(max_length=200, verbose_name=_("Name"), unique=False) - description = models.TextField(null=True, blank=True, verbose_name=_("Description")) - - class Meta: - abstract = True - - def __str__(self) -> str: - return self.name - class AbstractBaseModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -28,7 +14,6 @@ class AbstractBaseModel(models.Model): class Meta: abstract = True - ordering = ["name"] def scoped_id(self, scope: models.QuerySet) -> int: """ @@ -121,6 +106,24 @@ def save(self, *args, **kwargs) -> None: super().save(*args, **kwargs) + +class NameDescriptionMixin(AbstractBaseModel): + """ + Mixin for models that have a name and a description. + """ + + name = models.CharField(max_length=200, verbose_name=_("Name"), unique=False) + description = models.TextField(null=True, blank=True, verbose_name=_("Description")) + + class Meta: + abstract = True + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + + class ReferentialObjectMixin(AbstractBaseModel): """ Mixin for models that have a name and a description. diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py index 6b3a191e6b..ae08bf983d 100644 --- a/backend/core/migrations/0001_initial.py +++ b/backend/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-23 00:51 +# Generated by Django 5.0.2 on 2024-02-24 18:49 import core.validators import uuid @@ -16,10 +16,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Asset', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('business_value', models.CharField(blank=True, max_length=200, verbose_name='business value')), ('type', models.CharField(choices=[('PR', 'Primary'), ('SP', 'Support')], default='SP', max_length=2, verbose_name='type')), ('is_published', models.BooleanField(default=True, verbose_name='published')), @@ -32,10 +32,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='ComplianceAssessment', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('version', models.CharField(blank=True, default='1.0', help_text='Version of the compliance assessment (eg. 1.0, 2.0, etc.)', max_length=100, null=True, verbose_name='Version')), ('status', models.CharField(choices=[('planned', 'Planned'), ('in_progress', 'In progress'), ('in_review', 'In review'), ('done', 'Done'), ('deprecated', 'Deprecated')], default='planned', max_length=100, verbose_name='Status')), ('eta', models.DateField(blank=True, help_text='Estimated time of arrival', null=True, verbose_name='ETA')), @@ -50,10 +50,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Evidence', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('attachment', models.FileField(blank=True, help_text='Attachment for evidence (eg. screenshot, log file, etc.)', null=True, upload_to='', validators=[core.validators.validate_file_size, core.validators.validate_file_name], verbose_name='Attachment')), ('link', models.URLField(blank=True, help_text='Link to the evidence (eg. Jira ticket, etc.)', null=True, verbose_name='Link')), ], @@ -105,10 +105,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Project', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('internal_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Internal reference')), ('lc_status', models.CharField(choices=[('undefined', '--'), ('in_design', 'Design'), ('in_dev', 'Development'), ('in_prod', 'Production'), ('eol', 'End Of Life'), ('dropped', 'Dropped')], default='in_design', max_length=20, verbose_name='Status')), ], @@ -177,10 +177,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RiskAcceptance', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('state', models.CharField(choices=[('created', 'Created'), ('submitted', 'Submitted'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('revoked', 'Revoked')], default='created', max_length=20, verbose_name='State')), ('expiry_date', models.DateField(help_text='Specify when the risk acceptance will no longer apply', null=True, verbose_name='Expiry date')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), @@ -198,10 +198,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RiskAssessment', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('version', models.CharField(blank=True, default='1.0', help_text='Version of the compliance assessment (eg. 1.0, 2.0, etc.)', max_length=100, null=True, verbose_name='Version')), ('status', models.CharField(choices=[('planned', 'Planned'), ('in_progress', 'In progress'), ('in_review', 'In review'), ('done', 'Done'), ('deprecated', 'Deprecated')], default='planned', max_length=100, verbose_name='Status')), ('eta', models.DateField(blank=True, help_text='Estimated time of arrival', null=True, verbose_name='ETA')), @@ -236,10 +236,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RiskScenario', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('existing_measures', models.TextField(blank=True, help_text='The existing security measures to manage this risk. Edit the risk scenario to add extra security measures.', max_length=2000, verbose_name='Existing measures')), ('current_proba', models.SmallIntegerField(default=-1, verbose_name='Current probability')), ('current_impact', models.SmallIntegerField(default=-1, verbose_name='Current impact')), @@ -282,9 +282,9 @@ class Migration(migrations.Migration): migrations.CreateModel( name='SecurityMeasure', fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('category', models.CharField(blank=True, choices=[('policy', 'Policy'), ('process', 'Process'), ('technical', 'Technical'), ('physical', 'Physical')], max_length=20, null=True, verbose_name='Category')), ('status', models.CharField(blank=True, choices=[('planned', 'Planned'), ('active', 'Active'), ('inactive', 'Inactive')], max_length=20, null=True, verbose_name='Status')), ('eta', models.DateField(blank=True, help_text='Estimated Time of Arrival', null=True, verbose_name='ETA')), diff --git a/backend/core/migrations/0002_initial.py b/backend/core/migrations/0002_initial.py index cf044a7d60..cef9d4a13a 100644 --- a/backend/core/migrations/0002_initial.py +++ b/backend/core/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-23 00:51 +# Generated by Django 5.0.2 on 2024-02-24 18:49 import django.db.models.deletion import iam.models diff --git a/backend/core/models.py b/backend/core/models.py index 3999bc6422..e5e3bef744 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -21,7 +21,7 @@ User = get_user_model() -class Library(ReferentialObjectMixin, AbstractBaseModel, FolderMixin): +class Library(ReferentialObjectMixin, FolderMixin): copyright = models.CharField( max_length=4096, null=True, blank=True, verbose_name=_("Copyright") ) @@ -81,7 +81,7 @@ def delete(self, *args, **kwargs): super(Library, self).delete(*args, **kwargs) -class Assessment(AbstractBaseModel, NameDescriptionMixin): +class Assessment(NameDescriptionMixin): class Status(models.TextChoices): PLANNED = "planned", _("Planned") IN_PROGRESS = "in_progress", _("In progress") @@ -137,7 +137,7 @@ class Meta: abstract = True -class Project(AbstractBaseModel, NameDescriptionMixin, FolderMixin): +class Project(NameDescriptionMixin, FolderMixin): PRJ_LC_STATUS = [ ("undefined", _("--")), ("in_design", _("Design")), @@ -181,7 +181,7 @@ def __str__(self): return self.name -class Threat(ReferentialObjectMixin, AbstractBaseModel, RootFolderMixin): +class Threat(ReferentialObjectMixin, RootFolderMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, null=True, blank=True, related_name="threats" ) @@ -207,7 +207,7 @@ def __str__(self): return self.name -class Asset(AbstractBaseModel, NameDescriptionMixin, RootFolderMixin): +class Asset(NameDescriptionMixin, RootFolderMixin): class Type(models.TextChoices): """ The type of the asset. @@ -259,7 +259,7 @@ def ancestors_plus_self(self) -> list[Self]: return list(result) -class SecurityFunction(ReferentialObjectMixin, AbstractBaseModel, RootFolderMixin): +class SecurityFunction(ReferentialObjectMixin, RootFolderMixin): CATEGORY = [ ("policy", _("Policy")), ("process", _("Process")), @@ -308,7 +308,7 @@ def __str__(self): return self.name -class RiskMatrix(ReferentialObjectMixin, AbstractBaseModel, FolderMixin): +class RiskMatrix(ReferentialObjectMixin, FolderMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, @@ -624,7 +624,7 @@ def risk_scoring(probability, impact, risk_matrix: RiskMatrix) -> int: return risk_index -class Evidence(AbstractBaseModel, NameDescriptionMixin, FolderMixin): +class Evidence(NameDescriptionMixin, FolderMixin): # TODO: Manage file upload to S3/MiniO attachment = models.FileField( # upload_to=settings.LOCAL_STORAGE_DIRECTORY, @@ -688,7 +688,7 @@ def preview(self): return "" -class SecurityMeasure(AbstractBaseModel, NameDescriptionMixin, FolderMixin): +class SecurityMeasure(NameDescriptionMixin, FolderMixin): class Status(models.TextChoices): PLANNED = "planned", _("Planned") ACTIVE = "active", _("Active") @@ -847,7 +847,7 @@ def save(self, *args, **kwargs): super(Policy, self).save(*args, **kwargs) -class RiskScenario(AbstractBaseModel, NameDescriptionMixin): +class RiskScenario(NameDescriptionMixin): TREATMENT_OPTIONS = [ ("open", _("Open")), ("mitigate", _("Mitigate")), @@ -1040,7 +1040,7 @@ def save(self, *args, **kwargs): super(RiskScenario, self).save(*args, **kwargs) -class RiskAcceptance(AbstractBaseModel, NameDescriptionMixin, FolderMixin): +class RiskAcceptance(NameDescriptionMixin, FolderMixin): ACCEPTANCE_STATE = [ ("created", _("Created")), ("submitted", _("Submitted")), @@ -1128,7 +1128,7 @@ def set_state(self, state): self.save() -class Framework(ReferentialObjectMixin, AbstractBaseModel, FolderMixin): +class Framework(ReferentialObjectMixin, FolderMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, @@ -1162,7 +1162,7 @@ def is_deletable(self) -> bool: return True -class RequirementLevel(ReferentialObjectMixin, AbstractBaseModel, FolderMixin): +class RequirementLevel(ReferentialObjectMixin, FolderMixin): framework = models.ForeignKey( Framework, on_delete=models.CASCADE, @@ -1177,7 +1177,7 @@ class Meta: verbose_name_plural = _("Requirements levels") -class RequirementNode(ReferentialObjectMixin, AbstractBaseModel, FolderMixin): +class RequirementNode(ReferentialObjectMixin, FolderMixin): threats = models.ManyToManyField( "Threat", blank=True, diff --git a/backend/iam/migrations/0001_initial.py b/backend/iam/migrations/0001_initial.py index 55092e2df7..d5b22163bd 100644 --- a/backend/iam/migrations/0001_initial.py +++ b/backend/iam/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-23 00:51 +# Generated by Django 5.0.2 on 2024-02-24 18:49 import django.db.models.deletion import django.utils.timezone @@ -20,10 +20,10 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Folder', fields=[ - ('name', models.CharField(max_length=200, verbose_name='Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('content_type', models.CharField(choices=[('GL', 'GLOBAL'), ('DO', 'DOMAIN')], default='DO', max_length=2)), ('builtin', models.BooleanField(default=False)), ('parent_folder', models.ForeignKey(default=iam.models._get_root_folder, null=True, on_delete=django.db.models.deletion.CASCADE, to='iam.folder', verbose_name='parent folder')), @@ -46,7 +46,7 @@ class Migration(migrations.Migration): ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('folder', models.ForeignKey(default=iam.models._get_root_folder, on_delete=django.db.models.deletion.CASCADE, to='iam.folder', verbose_name='Folder')), + ('folder', models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder')), ], options={ 'verbose_name': 'user', @@ -60,19 +60,27 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Role', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=150, verbose_name='name')), - ('builtin', models.BooleanField(default=False)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('folder', models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder')), ('permissions', models.ManyToManyField(blank=True, to='auth.permission', verbose_name='permissions')), ], + options={ + 'ordering': ['name'], + 'abstract': False, + }, ), migrations.CreateModel( name='UserGroup', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=150, verbose_name='name')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('builtin', models.BooleanField(default=False)), - ('folder', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='iam.folder', verbose_name='Domain')), + ('folder', models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder')), ], options={ 'verbose_name': 'user group', @@ -82,15 +90,22 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RoleAssignment', fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('is_recursive', models.BooleanField(default=False, verbose_name='sub folders are visible')), ('builtin', models.BooleanField(default=False)), - ('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='iam.folder', verbose_name='Folder')), + ('folder', models.ForeignKey(default=iam.models._get_root_folder, on_delete=django.db.models.deletion.CASCADE, to='iam.folder', verbose_name='Folder')), ('perimeter_folders', models.ManyToManyField(related_name='perimeter_folders', to='iam.folder', verbose_name='Domain')), ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='iam.role', verbose_name='Role')), ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user_group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='iam.usergroup')), ], + options={ + 'ordering': ['name'], + 'abstract': False, + }, ), migrations.AddField( model_name='user', diff --git a/backend/iam/migrations/0002_role_builtin.py b/backend/iam/migrations/0002_role_builtin.py new file mode 100644 index 0000000000..58493b3aff --- /dev/null +++ b/backend/iam/migrations/0002_role_builtin.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-24 18:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('iam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='builtin', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/iam/models.py b/backend/iam/models.py index b913a2cd86..7da81c3329 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -45,58 +45,6 @@ logger = structlog.get_logger(__name__) -class UserGroup(models.Model): - """UserGroup objects contain users and can be used as principals in role assignments""" - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - folder = models.ForeignKey( - "Folder", verbose_name=_("Domain"), on_delete=models.CASCADE, default=None - ) - name = models.CharField(_("name"), max_length=150, unique=False) - builtin = models.BooleanField(default=False) - - class Meta: - """for Model""" - - verbose_name = _("user group") - verbose_name_plural = _("user groups") - - def __str__(self) -> str: - if self.builtin: - return f"{self.folder.name} - {BUILTIN_USERGROUP_CODENAMES.get(self.name)}" - return self.name - - def get_name_display(self) -> str: - return self.name - - @staticmethod - def get_user_groups(user): - # pragma pylint: disable=no-member - """get the list of user groups containing the user given in parameter""" - user_group_list = [] - for user_group in UserGroup.objects.all(): - if user in user_group.user_set.all(): - user_group_list.append(user_group) - return user_group_list - - -class Role(models.Model): - """A role is a list of permissions""" - - permissions = models.ManyToManyField( - Permission, - verbose_name=_("permissions"), - blank=True, - ) - name = models.CharField(_("name"), max_length=150, unique=False) - builtin = models.BooleanField(default=False) - - def __str__(self) -> str: - if self.builtin: - return f"{BUILTIN_ROLE_CODENAMES.get(self.name)}" - return self.name - - def _get_root_folder(): """helper function outside of class to facilitate serialization to be used only in Folder class""" @@ -106,7 +54,7 @@ def _get_root_folder(): return None -class Folder(AbstractBaseModel, NameDescriptionMixin): +class Folder(NameDescriptionMixin): """A folder is a container for other folders or any object Folders are organized in a tree structure, with a single root folder Folders are the base perimeter for role assignments @@ -216,6 +164,7 @@ def get_folder(obj: Any): return None + class FolderMixin(models.Model): """ Add foreign key to Folder @@ -231,6 +180,7 @@ class Meta: abstract = True + class RootFolderMixin(FolderMixin): """ Add foreign key to Folder, defaults to root folder @@ -247,6 +197,38 @@ class Meta: abstract = True + +class UserGroup(NameDescriptionMixin, RootFolderMixin): + """UserGroup objects contain users and can be used as principals in role assignments""" + + builtin = models.BooleanField(default=False) + + class Meta: + """for Model""" + + verbose_name = _("user group") + verbose_name_plural = _("user groups") + + def __str__(self) -> str: + if self.builtin: + return f"{self.folder.name} - {BUILTIN_USERGROUP_CODENAMES.get(self.name)}" + return self.name + + def get_name_display(self) -> str: + return self.name + + @staticmethod + def get_user_groups(user): + # pragma pylint: disable=no-member + """get the list of user groups containing the user given in parameter""" + user_group_list = [] + for user_group in UserGroup.objects.all(): + if user in user_group.user_set.all(): + user_group_list.append(user_group) + return user_group_list + + + class UserManager(BaseUserManager): use_in_migrations = True @@ -300,7 +282,7 @@ def create_superuser(self, email, password=None, **extra_fields): return superuser -class User(AbstractBaseUser): +class User(AbstractBaseUser, RootFolderMixin): """a user is a principal corresponding to a human""" try: @@ -335,12 +317,6 @@ class User(AbstractBaseUser): ), ) objects = UserManager() - folder = models.ForeignKey( - Folder, - on_delete=models.CASCADE, - verbose_name=_("Folder"), - default=_get_root_folder - ) except: logger.debug("Exception kludge") @@ -491,7 +467,25 @@ def set_username(self, username): self.email = username -class RoleAssignment(models.Model): + +class Role(NameDescriptionMixin, RootFolderMixin): + """A role is a list of permissions""" + + permissions = models.ManyToManyField( + Permission, + verbose_name=_("permissions"), + blank=True, + ) + builtin = models.BooleanField(default=False) + + def __str__(self) -> str: + if self.builtin: + return f"{BUILTIN_ROLE_CODENAMES.get(self.name)}" + return self.name + + + +class RoleAssignment(NameDescriptionMixin, RootFolderMixin): """fundamental class for CISO Assistant RBAC model, similar to Azure IAM model""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -506,7 +500,7 @@ class RoleAssignment(models.Model): is_recursive = models.BooleanField(_("sub folders are visible"), default=False) builtin = models.BooleanField(default=False) folder = models.ForeignKey( - Folder, on_delete=models.CASCADE, verbose_name=_("Folder") + "Folder", verbose_name=_("Folder"), on_delete=models.CASCADE, default=_get_root_folder ) def __str__(self) -> str: From 34a33ffe79cbdd877cea4b133878434ec770e63a Mon Sep 17 00:00:00 2001 From: eric-intuitem <71850047+eric-intuitem@users.noreply.github.com> Date: Sun, 25 Feb 2024 02:03:15 +0100 Subject: [PATCH 2/9] SImplify data model Fusion RootFolderMixin and FolderMixin Move RefentialObjectMixin in core (where it is only used) Move updated_at in AbstracBaseModel Remove redundant fields (UUID, folder) --- backend/cal/migrations/0001_initial.py | 2 +- backend/core/apps.py | 2 +- backend/core/base_models.py | 55 +----------- backend/core/migrations/0001_initial.py | 24 +++-- backend/core/migrations/0002_initial.py | 22 ++--- backend/core/models.py | 77 ++++++++++++---- backend/iam/migrations/0001_initial.py | 13 ++- backend/iam/migrations/0002_role_builtin.py | 18 ---- backend/iam/models.py | 97 ++++++++------------- 9 files changed, 140 insertions(+), 170 deletions(-) delete mode 100644 backend/iam/migrations/0002_role_builtin.py diff --git a/backend/cal/migrations/0001_initial.py b/backend/cal/migrations/0001_initial.py index a852e947d5..6a820714bc 100644 --- a/backend/cal/migrations/0001_initial.py +++ b/backend/cal/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-24 18:49 +# Generated by Django 5.0.2 on 2024-02-25 00:59 from django.db import migrations, models diff --git a/backend/core/apps.py b/backend/core/apps.py index 19bfdfe73a..418b5ec748 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -11,7 +11,7 @@ def startup(**kwargs): """ from django.contrib.auth.models import Permission from iam.models import Folder, Role, RoleAssignment, User, UserGroup - print("startup handler: initialize database", kwargs) + print("startup handler: initialize database") auditor_permissions = Permission.objects.filter( codename__in=[ diff --git a/backend/core/base_models.py b/backend/core/base_models.py index 024019966b..13c77609ae 100644 --- a/backend/core/base_models.py +++ b/backend/core/base_models.py @@ -11,6 +11,7 @@ class AbstractBaseModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("UpdatedÒ at")) class Meta: abstract = True @@ -121,57 +122,3 @@ class Meta: def __str__(self) -> str: return self.name - - - -class ReferentialObjectMixin(AbstractBaseModel): - """ - Mixin for models that have a name and a description. - """ - - urn = models.CharField( - max_length=100, null=True, blank=True, unique=True, verbose_name=_("URN") - ) - ref_id = models.CharField( - max_length=100, blank=True, null=True, verbose_name=_("Reference ID") - ) - locale = models.CharField( - max_length=100, null=False, blank=False, default="en", verbose_name=_("Locale") - ) - default_locale = models.BooleanField(default=True, verbose_name=_("Default locale")) - provider = models.CharField( - max_length=200, blank=True, null=True, verbose_name=_("Provider") - ) - name = models.CharField( - null=True, max_length=200, verbose_name=_("Name"), unique=False - ) - description = models.TextField(null=True, blank=True, verbose_name=_("Description")) - annotation = models.TextField(null=True, blank=True, verbose_name=_("Annotation")) - - class Meta: - abstract = True - - def display_short(self) -> str: - _name = ( - self.ref_id - if not self.name - else self.name - if not self.ref_id - else f"{self.ref_id} - {self.name}" - ) - _name = "" if not _name else _name - return _name - - def display_long(self) -> str: - _name = self.display_short() - _display = ( - _name - if not self.description - else self.description - if _name == "" - else f"{_name}: {self.description}" - ) - return _display - - def __str__(self) -> str: - return self.display_short() diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py index ae08bf983d..f1823cc264 100644 --- a/backend/core/migrations/0001_initial.py +++ b/backend/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-24 18:49 +# Generated by Django 5.0.2 on 2024-02-25 00:59 import core.validators import uuid @@ -18,6 +18,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('business_value', models.CharField(blank=True, max_length=200, verbose_name='business value')), @@ -34,6 +35,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('version', models.CharField(blank=True, default='1.0', help_text='Version of the compliance assessment (eg. 1.0, 2.0, etc.)', max_length=100, null=True, verbose_name='Version')), @@ -52,6 +54,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('attachment', models.FileField(blank=True, help_text='Attachment for evidence (eg. screenshot, log file, etc.)', null=True, upload_to='', validators=[core.validators.validate_file_size, core.validators.validate_file_name], verbose_name='Attachment')), @@ -67,6 +70,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), @@ -86,6 +90,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), @@ -107,6 +112,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('internal_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Internal reference')), @@ -122,6 +128,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('status', models.CharField(choices=[('to_do', 'To do'), ('in_progress', 'In progress'), ('non_compliant', 'Non compliant'), ('partially_compliant', 'Partially compliant'), ('compliant', 'Compliant'), ('not_applicable', 'Not applicable')], default='to_do', max_length=100, verbose_name='Status')), ('observation', models.TextField(blank=True, null=True, verbose_name='Observation')), ], @@ -135,6 +142,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), @@ -155,6 +163,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), @@ -179,11 +188,11 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('state', models.CharField(choices=[('created', 'Created'), ('submitted', 'Submitted'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('revoked', 'Revoked')], default='created', max_length=20, verbose_name='State')), ('expiry_date', models.DateField(help_text='Specify when the risk acceptance will no longer apply', null=True, verbose_name='Expiry date')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('accepted_at', models.DateTimeField(blank=True, null=True, verbose_name='Acceptance date')), ('rejected_at', models.DateTimeField(blank=True, null=True, verbose_name='Rejection date')), ('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='Revocation date')), @@ -200,13 +209,13 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('version', models.CharField(blank=True, default='1.0', help_text='Version of the compliance assessment (eg. 1.0, 2.0, etc.)', max_length=100, null=True, verbose_name='Version')), ('status', models.CharField(choices=[('planned', 'Planned'), ('in_progress', 'In progress'), ('in_review', 'In review'), ('done', 'Done'), ('deprecated', 'Deprecated')], default='planned', max_length=100, verbose_name='Status')), ('eta', models.DateField(blank=True, help_text='Estimated time of arrival', null=True, verbose_name='ETA')), ('due_date', models.DateField(blank=True, help_text='Due date', null=True, verbose_name='Due date')), - ('updated_at', models.DateTimeField(auto_now=True)), ], options={ 'verbose_name': 'Risk assessment', @@ -218,6 +227,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), @@ -238,6 +248,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('existing_measures', models.TextField(blank=True, help_text='The existing security measures to manage this risk. Edit the risk scenario to add extra security measures.', max_length=2000, verbose_name='Existing measures')), @@ -248,7 +259,6 @@ class Migration(migrations.Migration): ('residual_impact', models.SmallIntegerField(default=-1, verbose_name='Residual impact')), ('residual_level', models.SmallIntegerField(default=-1, help_text='The risk level when all the extra measures are done. Automatically updated on Save, based on the chosen risk matrix', verbose_name='Residual level')), ('treatment', models.CharField(choices=[('open', 'Open'), ('mitigate', 'Mitigate'), ('accept', 'Accept'), ('avoid', 'Avoid'), ('transfer', 'Transfer')], default='open', max_length=20, verbose_name='Treatment status')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ('strength_of_knowledge', models.CharField(choices=[('--', '--'), ('0', 'Low'), ('1', 'Medium'), ('2', 'High')], default='--', max_length=20, verbose_name='Strength of Knowledge')), ('justification', models.CharField(blank=True, max_length=500, null=True, verbose_name='Justification')), ], @@ -262,6 +272,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), @@ -283,6 +294,8 @@ class Migration(migrations.Migration): name='SecurityMeasure', fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('category', models.CharField(blank=True, choices=[('policy', 'Policy'), ('process', 'Process'), ('technical', 'Technical'), ('physical', 'Physical')], max_length=20, null=True, verbose_name='Category')), @@ -291,8 +304,6 @@ class Migration(migrations.Migration): ('expiry_date', models.DateField(blank=True, help_text='Date after which the security measure is no longer valid', null=True, verbose_name='Expiry date')), ('link', models.CharField(blank=True, help_text='External url for action follow-up (eg. Jira ticket)', max_length=1000, null=True, verbose_name='Link')), ('effort', models.CharField(blank=True, choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'Extra-Large')], help_text='Relative effort of the measure (using T-Shirt sizing)', max_length=2, null=True, verbose_name='Effort')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), ], options={ 'verbose_name': 'Security measure', @@ -304,6 +315,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('urn', models.CharField(blank=True, max_length=100, null=True, unique=True, verbose_name='URN')), ('ref_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Reference ID')), ('locale', models.CharField(default='en', max_length=100, verbose_name='Locale')), diff --git a/backend/core/migrations/0002_initial.py b/backend/core/migrations/0002_initial.py index cef9d4a13a..60119ec1ab 100644 --- a/backend/core/migrations/0002_initial.py +++ b/backend/core/migrations/0002_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-24 18:49 +# Generated by Django 5.0.2 on 2024-02-25 00:59 import django.db.models.deletion import iam.models @@ -40,12 +40,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='evidence', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='framework', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='complianceassessment', @@ -60,7 +60,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='library', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='framework', @@ -70,7 +70,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='project', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='complianceassessment', @@ -90,12 +90,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='requirementassessment', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='requirementlevel', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='requirementlevel', @@ -105,7 +105,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='requirementnode', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='requirementnode', @@ -125,7 +125,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='riskacceptance', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='riskassessment', @@ -145,7 +145,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='riskmatrix', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='riskmatrix', @@ -195,7 +195,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='securitymeasure', name='folder', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), + field=models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder'), ), migrations.AddField( model_name='securitymeasure', diff --git a/backend/core/models.py b/backend/core/models.py index e5e3bef744..113e0e20ae 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -7,7 +7,7 @@ from .base_models import * from .validators import validate_file_size, validate_file_name -from iam.models import FolderMixin, RootFolderMixin +from iam.models import FolderMixin from django.core import serializers import os @@ -21,7 +21,60 @@ User = get_user_model() -class Library(ReferentialObjectMixin, FolderMixin): +class ReferentialObjectMixin(NameDescriptionMixin, FolderMixin): + """ + Mixin for referential objects. + """ + + urn = models.CharField( + max_length=100, null=True, blank=True, unique=True, verbose_name=_("URN") + ) + ref_id = models.CharField( + max_length=100, blank=True, null=True, verbose_name=_("Reference ID") + ) + locale = models.CharField( + max_length=100, null=False, blank=False, default="en", verbose_name=_("Locale") + ) + default_locale = models.BooleanField(default=True, verbose_name=_("Default locale")) + provider = models.CharField( + max_length=200, blank=True, null=True, verbose_name=_("Provider") + ) + name = models.CharField( + null=True, max_length=200, verbose_name=_("Name"), unique=False + ) + description = models.TextField(null=True, blank=True, verbose_name=_("Description")) + annotation = models.TextField(null=True, blank=True, verbose_name=_("Annotation")) + + class Meta: + abstract = True + + def display_short(self) -> str: + _name = ( + self.ref_id + if not self.name + else self.name + if not self.ref_id + else f"{self.ref_id} - {self.name}" + ) + _name = "" if not _name else _name + return _name + + def display_long(self) -> str: + _name = self.display_short() + _display = ( + _name + if not self.description + else self.description + if _name == "" + else f"{_name}: {self.description}" + ) + return _display + + def __str__(self) -> str: + return self.display_short() + + +class Library(ReferentialObjectMixin): copyright = models.CharField( max_length=4096, null=True, blank=True, verbose_name=_("Copyright") ) @@ -181,7 +234,7 @@ def __str__(self): return self.name -class Threat(ReferentialObjectMixin, RootFolderMixin): +class Threat(ReferentialObjectMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, null=True, blank=True, related_name="threats" ) @@ -207,7 +260,7 @@ def __str__(self): return self.name -class Asset(NameDescriptionMixin, RootFolderMixin): +class Asset(NameDescriptionMixin, FolderMixin): class Type(models.TextChoices): """ The type of the asset. @@ -259,7 +312,7 @@ def ancestors_plus_self(self) -> list[Self]: return list(result) -class SecurityFunction(ReferentialObjectMixin, RootFolderMixin): +class SecurityFunction(ReferentialObjectMixin): CATEGORY = [ ("policy", _("Policy")), ("process", _("Process")), @@ -308,7 +361,7 @@ def __str__(self): return self.name -class RiskMatrix(ReferentialObjectMixin, FolderMixin): +class RiskMatrix(ReferentialObjectMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, @@ -376,7 +429,6 @@ class RiskAssessment(Assessment): help_text=_("WARNING! After choosing it, you will not be able to change it"), verbose_name=_("Risk matrix"), ) - updated_at = models.DateTimeField(auto_now=True) class Meta: verbose_name = _("Risk assessment") @@ -760,9 +812,6 @@ class Status(models.TextChoices): verbose_name=_("Effort"), ) - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) - fields_to_check = ["name", "category"] class Meta: @@ -934,7 +983,6 @@ class RiskScenario(NameDescriptionMixin): verbose_name=_("Treatment status"), ) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) strength_of_knowledge = models.CharField( max_length=20, choices=SOK_OPTIONS, @@ -1076,7 +1124,6 @@ class RiskAcceptance(NameDescriptionMixin, FolderMixin): null=True, verbose_name=_("Expiry date"), ) - updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) accepted_at = models.DateTimeField( blank=True, null=True, verbose_name=_("Acceptance date") ) @@ -1128,7 +1175,7 @@ def set_state(self, state): self.save() -class Framework(ReferentialObjectMixin, FolderMixin): +class Framework(ReferentialObjectMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, @@ -1162,7 +1209,7 @@ def is_deletable(self) -> bool: return True -class RequirementLevel(ReferentialObjectMixin, FolderMixin): +class RequirementLevel(ReferentialObjectMixin): framework = models.ForeignKey( Framework, on_delete=models.CASCADE, @@ -1177,7 +1224,7 @@ class Meta: verbose_name_plural = _("Requirements levels") -class RequirementNode(ReferentialObjectMixin, FolderMixin): +class RequirementNode(ReferentialObjectMixin): threats = models.ManyToManyField( "Threat", blank=True, diff --git a/backend/iam/migrations/0001_initial.py b/backend/iam/migrations/0001_initial.py index d5b22163bd..1576eb0d7b 100644 --- a/backend/iam/migrations/0001_initial.py +++ b/backend/iam/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-24 18:49 +# Generated by Django 5.0.2 on 2024-02-25 00:59 import django.db.models.deletion import django.utils.timezone @@ -22,6 +22,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('content_type', models.CharField(choices=[('GL', 'GLOBAL'), ('DO', 'DOMAIN')], default='DO', max_length=2)), @@ -39,6 +40,8 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), ('email', models.CharField(max_length=100, unique=True)), @@ -62,8 +65,10 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('builtin', models.BooleanField(default=False)), ('folder', models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder')), ('permissions', models.ManyToManyField(blank=True, to='auth.permission', verbose_name='permissions')), ], @@ -77,6 +82,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), ('builtin', models.BooleanField(default=False)), @@ -90,13 +96,14 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RoleAssignment', fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='UpdatedÒ at')), ('name', models.CharField(max_length=200, verbose_name='Name')), ('description', models.TextField(blank=True, null=True, verbose_name='Description')), - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('is_recursive', models.BooleanField(default=False, verbose_name='sub folders are visible')), ('builtin', models.BooleanField(default=False)), - ('folder', models.ForeignKey(default=iam.models._get_root_folder, on_delete=django.db.models.deletion.CASCADE, to='iam.folder', verbose_name='Folder')), + ('folder', models.ForeignKey(default=iam.models.Folder.get_root_folder, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_folder', to='iam.folder')), ('perimeter_folders', models.ManyToManyField(related_name='perimeter_folders', to='iam.folder', verbose_name='Domain')), ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='iam.role', verbose_name='Role')), ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), diff --git a/backend/iam/migrations/0002_role_builtin.py b/backend/iam/migrations/0002_role_builtin.py deleted file mode 100644 index 58493b3aff..0000000000 --- a/backend/iam/migrations/0002_role_builtin.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-02-24 18:52 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('iam', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='role', - name='builtin', - field=models.BooleanField(default=False), - ), - ] diff --git a/backend/iam/models.py b/backend/iam/models.py index 7da81c3329..6879dc2157 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -114,7 +114,7 @@ def get_parent_folders(self) -> List[Self]: ) @staticmethod - def navigate_structure(start, path): + def _navigate_structure(start, path): """ Navigate through a mixed structure of objects and dictionaries. @@ -156,7 +156,7 @@ def get_folder(obj: Any): # Attempt to traverse each path until a valid folder is found or all paths are exhausted. for path in paths: - folder = Folder.navigate_structure(obj, path) + folder = Folder._navigate_structure(obj, path) if folder is not None: return folder @@ -166,22 +166,6 @@ def get_folder(obj: Any): class FolderMixin(models.Model): - """ - Add foreign key to Folder - """ - - folder = models.ForeignKey( - Folder, - on_delete=models.CASCADE, - related_name="%(class)s_folder", - ) - - class Meta: - abstract = True - - - -class RootFolderMixin(FolderMixin): """ Add foreign key to Folder, defaults to root folder """ @@ -198,7 +182,7 @@ class Meta: -class UserGroup(NameDescriptionMixin, RootFolderMixin): +class UserGroup(NameDescriptionMixin, FolderMixin): """UserGroup objects contain users and can be used as principals in role assignments""" builtin = models.BooleanField(default=False) @@ -282,44 +266,39 @@ def create_superuser(self, email, password=None, **extra_fields): return superuser -class User(AbstractBaseUser, RootFolderMixin): +class User(AbstractBaseUser, AbstractBaseModel, FolderMixin): """a user is a principal corresponding to a human""" - try: - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - last_name = models.CharField(_("last name"), max_length=150, blank=True) - first_name = models.CharField(_("first name"), max_length=150, blank=True) - email = models.CharField(max_length=100, unique=True) - first_login = models.BooleanField(default=True) - is_active = models.BooleanField( - _("active"), - default=True, - help_text=_( - "Designates whether this user should be treated as active. " - "Unselect this instead of deleting accounts." - ), - ) - date_joined = models.DateTimeField(_("date joined"), default=timezone.now) - is_superuser = models.BooleanField( - _("superuser status"), - default=False, - help_text=_( - "Designates that this user has all permissions without explicitly assigning them." - ), - ) - user_groups = models.ManyToManyField( - UserGroup, - verbose_name=_("user groups"), - blank=True, - help_text=_( - "The user groups this user belongs to. A user will get all permissions " - "granted to each of their user groups." - ), - ) - objects = UserManager() - - except: - logger.debug("Exception kludge") + last_name = models.CharField(_("last name"), max_length=150, blank=True) + first_name = models.CharField(_("first name"), max_length=150, blank=True) + email = models.CharField(max_length=100, unique=True) + first_login = models.BooleanField(default=True) + is_active = models.BooleanField( + _("active"), + default=True, + help_text=_( + "Designates whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + ) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) + is_superuser = models.BooleanField( + _("superuser status"), + default=False, + help_text=_( + "Designates that this user has all permissions without explicitly assigning them." + ), + ) + user_groups = models.ManyToManyField( + UserGroup, + verbose_name=_("user groups"), + blank=True, + help_text=_( + "The user groups this user belongs to. A user will get all permissions " + "granted to each of their user groups." + ), + ) + objects = UserManager() # USERNAME_FIELD is used as the unique identifier for the user # and is required by Django to be set to a non-empty value. @@ -468,7 +447,7 @@ def set_username(self, username): -class Role(NameDescriptionMixin, RootFolderMixin): +class Role(NameDescriptionMixin, FolderMixin): """A role is a list of permissions""" permissions = models.ManyToManyField( @@ -485,10 +464,9 @@ def __str__(self) -> str: -class RoleAssignment(NameDescriptionMixin, RootFolderMixin): +class RoleAssignment(NameDescriptionMixin, FolderMixin): """fundamental class for CISO Assistant RBAC model, similar to Azure IAM model""" - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) perimeter_folders = models.ManyToManyField( "Folder", verbose_name=_("Domain"), related_name="perimeter_folders" ) @@ -499,9 +477,6 @@ class RoleAssignment(NameDescriptionMixin, RootFolderMixin): role = models.ForeignKey(Role, on_delete=models.CASCADE, verbose_name=_("Role")) is_recursive = models.BooleanField(_("sub folders are visible"), default=False) builtin = models.BooleanField(default=False) - folder = models.ForeignKey( - "Folder", verbose_name=_("Folder"), on_delete=models.CASCADE, default=_get_root_folder - ) def __str__(self) -> str: # pragma pylint: disable=no-member From 12f98d48c07f6ed924e6ebb0634c9e473227dd74 Mon Sep 17 00:00:00 2001 From: eric-intuitem <71850047+eric-intuitem@users.noreply.github.com> Date: Sun, 25 Feb 2024 02:29:26 +0100 Subject: [PATCH 3/9] Update models.py Reorder classes in a logical way --- backend/core/models.py | 1515 ++++++++++++++++++++-------------------- 1 file changed, 763 insertions(+), 752 deletions(-) diff --git a/backend/core/models.py b/backend/core/models.py index 113e0e20ae..c46f296de8 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -20,6 +20,7 @@ User = get_user_model() +########################### Referential objects ######################### class ReferentialObjectMixin(NameDescriptionMixin, FolderMixin): """ @@ -134,106 +135,6 @@ def delete(self, *args, **kwargs): super(Library, self).delete(*args, **kwargs) -class Assessment(NameDescriptionMixin): - class Status(models.TextChoices): - PLANNED = "planned", _("Planned") - IN_PROGRESS = "in_progress", _("In progress") - IN_REVIEW = "in_review", _("In review") - DONE = "done", _("Done") - DEPRECATED = "deprecated", _("Deprecated") - - project = models.ForeignKey( - "Project", on_delete=models.CASCADE, verbose_name=_("Project") - ) - version = models.CharField( - max_length=100, - blank=True, - null=True, - help_text=_("Version of the compliance assessment (eg. 1.0, 2.0, etc.)"), - verbose_name=_("Version"), - default="1.0", - ) - status = models.CharField( - max_length=100, - choices=Status.choices, - default=Status.PLANNED, - verbose_name=_("Status"), - ) - authors = models.ManyToManyField( - User, - blank=True, - verbose_name=_("Authors"), - related_name="%(class)s_authors", - ) - reviewers = models.ManyToManyField( - User, - blank=True, - verbose_name=_("Reviewers"), - related_name="%(class)s_reviewers", - ) - eta = models.DateField( - null=True, - blank=True, - help_text=_("Estimated time of arrival"), - verbose_name=_("ETA"), - ) - due_date = models.DateField( - null=True, - blank=True, - help_text=_("Due date"), - verbose_name=_("Due date"), - ) - - fields_to_check = ["name", "version"] - - class Meta: - abstract = True - - -class Project(NameDescriptionMixin, FolderMixin): - PRJ_LC_STATUS = [ - ("undefined", _("--")), - ("in_design", _("Design")), - ("in_dev", _("Development")), - ("in_prod", _("Production")), - ("eol", _("End Of Life")), - ("dropped", _("Dropped")), - ] - internal_reference = models.CharField( - max_length=100, null=True, blank=True, verbose_name=_("Internal reference") - ) - lc_status = models.CharField( - max_length=20, - default="in_design", - choices=PRJ_LC_STATUS, - verbose_name=_("Status"), - ) - - class Meta: - verbose_name = _("Project") - verbose_name_plural = _("Projects") - - def overall_compliance(self): - compliance_assessments_list = [ - compliance_assessment - for compliance_assessment in self.compliance_assessment_set.all() - ] - count = ( - RequirementAssessment.objects.filter(status="compliant") - .filter(compliance_assessment__in=compliance_assessments_list) - .count() - ) - total = RequirementAssessment.objects.filter( - compliance_assessment__in=compliance_assessments_list - ).count() - if total == 0: - return 0 - return round(count * 100 / total) - - def __str__(self): - return self.name - - class Threat(ReferentialObjectMixin): library = models.ForeignKey( Library, on_delete=models.CASCADE, null=True, blank=True, related_name="threats" @@ -260,58 +161,6 @@ def __str__(self): return self.name -class Asset(NameDescriptionMixin, FolderMixin): - class Type(models.TextChoices): - """ - The type of the asset. - - An asset can either be a primary or a support asset. - A support asset can be linked to another "parent" asset of type primary or support. - Cycles are not allowed - """ - - PRIMARY = "PR", _("Primary") - SUPPORT = "SP", _("Support") - - business_value = models.CharField( - max_length=200, blank=True, verbose_name=_("business value") - ) - type = models.CharField( - max_length=2, choices=Type.choices, default=Type.SUPPORT, verbose_name=_("type") - ) - parent_assets = models.ManyToManyField( - "self", blank=True, verbose_name=_("parent assets"), symmetrical=False - ) - is_published = models.BooleanField(_("published"), default=True) - - fields_to_check = ["name"] - - class Meta: - verbose_name_plural = _("Assets") - verbose_name = _("Asset") - - def __str__(self) -> str: - return str(self.name) - - def is_primary(self) -> bool: - """ - Returns True if the asset is a primary asset. - """ - return self.type == Asset.Type.PRIMARY - - def is_support(self) -> bool: - """ - Returns True if the asset is a support asset. - """ - return self.type == Asset.Type.SUPPORT - - def ancestors_plus_self(self) -> list[Self]: - result = {self} - for x in self.parent_assets.all(): - result.update(x.ancestors_plus_self()) - return list(result) - - class SecurityFunction(ReferentialObjectMixin): CATEGORY = [ ("policy", _("Policy")), @@ -422,82 +271,541 @@ def __str__(self) -> str: return self.name -class RiskAssessment(Assessment): - risk_matrix = models.ForeignKey( - RiskMatrix, - on_delete=models.PROTECT, - help_text=_("WARNING! After choosing it, you will not be able to change it"), - verbose_name=_("Risk matrix"), +class Framework(ReferentialObjectMixin): + library = models.ForeignKey( + Library, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="frameworks", ) class Meta: - verbose_name = _("Risk assessment") - verbose_name_plural = _("Risk assessments") + verbose_name = _("Framework") + verbose_name_plural = _("Frameworks") - def __str__(self) -> str: - return f"{self.project.folder}/{self.project}/{self.name} - {self.version}" + def get_next_order_id(self, obj_type: models.Model, _parent_urn: str = None) -> int: + """ + Returns the next order id for a given object type + """ + if _parent_urn: + return ( + obj_type.objects.filter(framework=self, parent_urn=_parent_urn).count() + + 1 + ) + else: + return obj_type.objects.filter(framework=self).count() + 1 - @property - def path_display(self) -> str: - return f"{self.project.folder}/{self.project}/{self.name} - {self.version}" + def is_deletable(self) -> bool: + """ + Returns True if the framework can be deleted + """ + if self.compliance_assessment_set.count() > 0: + return False + return True - def get_scenario_count(self) -> int: - count = RiskScenario.objects.filter(risk_assessment=self.id).count() - scenario_count = count - return scenario_count - def quality_check(self) -> dict: - errors_lst = list() - warnings_lst = list() - info_lst = list() - # --- check on the risk risk_assessment: - _object = serializers.serialize("json", [self]) - _object = json.loads(_object) - if self.status == Assessment.Status.IN_PROGRESS: - info_lst.append( - { - "msg": _("{}: Risk assessment is still in progress").format( - str(self) - ), - "obj_type": "risk_assessment", - "object": _object, - } - ) - if not self.authors: - info_lst.append( - { - "msg": _( - "{}: No author assigned to this risk risk assessment" - ).format(str(self)), - "obj_type": "risk_assessment", - "object": _object, - } - ) - if not self.risk_scenarios.all(): - warnings_lst.append( - { - "msg": _( - "{}: RiskAssessment is empty. No risk scenario declared yet" - ).format(self), - "obj_type": "risk_assessment", - "object": _object, - } - ) - # --- +class RequirementLevel(ReferentialObjectMixin): + framework = models.ForeignKey( + Framework, + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_("Framework"), + ) + level = models.IntegerField(null=False, blank=False, verbose_name=_("Level")) - # --- checks on the risk scenarios - # TODO: Refactor this - _scenarios = serializers.serialize( - "json", self.risk_scenarios.all().order_by("created_at") - ) - scenarios = [x["fields"] for x in json.loads(_scenarios)] - for ri in scenarios: - if ri["current_level"] < 0: - warnings_lst.append( - { - "msg": _("{} current risk level has not been assessed").format( - ri["name"] - ), + class Meta: + verbose_name = _("Requirements level") + verbose_name_plural = _("Requirements levels") + + +class RequirementNode(ReferentialObjectMixin): + threats = models.ManyToManyField( + "Threat", + blank=True, + verbose_name=_("Threats"), + related_name="requirements", + ) + security_functions = models.ManyToManyField( + "SecurityFunction", + blank=True, + verbose_name=_("Security functions"), + related_name="requirements", + ) + framework = models.ForeignKey( + Framework, + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_("Framework"), + ) + parent_urn = models.CharField( + max_length=100, null=True, blank=True, verbose_name=_("Parent URN") + ) + order_id = models.IntegerField(null=True, verbose_name=_("Order ID")) + level = models.IntegerField(null=True, verbose_name=_("Level")) + maturity = models.IntegerField(null=True, verbose_name=_("Maturity")) + assessable = models.BooleanField(null=False, verbose_name=_("Assessable")) + + class Meta: + verbose_name = _("RequirementNode") + verbose_name_plural = _("RequirementNodes") + + +########################### Domain objects ######################### + +class Project(NameDescriptionMixin, FolderMixin): + PRJ_LC_STATUS = [ + ("undefined", _("--")), + ("in_design", _("Design")), + ("in_dev", _("Development")), + ("in_prod", _("Production")), + ("eol", _("End Of Life")), + ("dropped", _("Dropped")), + ] + internal_reference = models.CharField( + max_length=100, null=True, blank=True, verbose_name=_("Internal reference") + ) + lc_status = models.CharField( + max_length=20, + default="in_design", + choices=PRJ_LC_STATUS, + verbose_name=_("Status"), + ) + + class Meta: + verbose_name = _("Project") + verbose_name_plural = _("Projects") + + def overall_compliance(self): + compliance_assessments_list = [ + compliance_assessment + for compliance_assessment in self.compliance_assessment_set.all() + ] + count = ( + RequirementAssessment.objects.filter(status="compliant") + .filter(compliance_assessment__in=compliance_assessments_list) + .count() + ) + total = RequirementAssessment.objects.filter( + compliance_assessment__in=compliance_assessments_list + ).count() + if total == 0: + return 0 + return round(count * 100 / total) + + def __str__(self): + return self.name + + +class Asset(NameDescriptionMixin, FolderMixin): + class Type(models.TextChoices): + """ + The type of the asset. + + An asset can either be a primary or a support asset. + A support asset can be linked to another "parent" asset of type primary or support. + Cycles are not allowed + """ + + PRIMARY = "PR", _("Primary") + SUPPORT = "SP", _("Support") + + business_value = models.CharField( + max_length=200, blank=True, verbose_name=_("business value") + ) + type = models.CharField( + max_length=2, choices=Type.choices, default=Type.SUPPORT, verbose_name=_("type") + ) + parent_assets = models.ManyToManyField( + "self", blank=True, verbose_name=_("parent assets"), symmetrical=False + ) + is_published = models.BooleanField(_("published"), default=True) + + fields_to_check = ["name"] + + class Meta: + verbose_name_plural = _("Assets") + verbose_name = _("Asset") + + def __str__(self) -> str: + return str(self.name) + + def is_primary(self) -> bool: + """ + Returns True if the asset is a primary asset. + """ + return self.type == Asset.Type.PRIMARY + + def is_support(self) -> bool: + """ + Returns True if the asset is a support asset. + """ + return self.type == Asset.Type.SUPPORT + + def ancestors_plus_self(self) -> list[Self]: + result = {self} + for x in self.parent_assets.all(): + result.update(x.ancestors_plus_self()) + return list(result) + + +class Evidence(NameDescriptionMixin, FolderMixin): + # TODO: Manage file upload to S3/MiniO + attachment = models.FileField( + # upload_to=settings.LOCAL_STORAGE_DIRECTORY, + blank=True, + null=True, + help_text=_("Attachment for evidence (eg. screenshot, log file, etc.)"), + verbose_name=_("Attachment"), + validators=[validate_file_size, validate_file_name], + ) + link = models.URLField( + blank=True, + null=True, + help_text=_("Link to the evidence (eg. Jira ticket, etc.)"), + verbose_name=_("Link"), + ) + + class Meta: + verbose_name = _("Evidence") + verbose_name_plural = _("Evidences") + + def get_folder(self): + if self.security_measures: + return self.security_measures.first().folder + elif self.requirement_assessments: + return self.requirement_assessments.first().folder + else: + return None + + def filename(self): + return os.path.basename(self.attachment.name) + + def preview(self): + if self.attachment: + if self.filename().endswith((".png", ".jpg", ".jpeg")): + return ( + "image", + mark_safe(''.format(self.attachment.url)), + ) + if self.filename().endswith(".txt"): + with open(self.attachment.path, "r") as text: + return ("text", text.read()) + if self.filename().endswith(".pdf"): + return ( + "pdf", + mark_safe( + ''.format( + self.attachment.url + ) + ), + ) + if self.filename().endswith(".docx"): + return ( + "icon", + mark_safe(''.format("/static/icons/word.png")), + ) + if self.filename().endswith((".xls", ".xlsx", ".csv")): + return ( + "icon", + mark_safe(''.format("/static/icons/excel.png")), + ) + return "" + + +class SecurityMeasure(NameDescriptionMixin, FolderMixin): + class Status(models.TextChoices): + PLANNED = "planned", _("Planned") + ACTIVE = "active", _("Active") + INACTIVE = "inactive", _("Inactive") + + CATEGORY = SecurityFunction.CATEGORY + + EFFORT = [ + ("S", _("Small")), + ("M", _("Medium")), + ("L", _("Large")), + ("XL", _("Extra-Large")), + ] + + MAP_EFFORT = {None: -1, "S": 1, "M": 2, "L": 4, "XL": 8} + # todo: think about a smarter model for ranking + security_function = models.ForeignKey( + SecurityFunction, + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_("Security Function"), + ) + evidences = models.ManyToManyField( + Evidence, + blank=True, + verbose_name=_("Evidences"), + related_name="security_measures", + ) + category = models.CharField( + max_length=20, + choices=CATEGORY, + null=True, + blank=True, + verbose_name=_("Category"), + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + null=True, + blank=True, + verbose_name=_("Status"), + ) + eta = models.DateField( + blank=True, + null=True, + help_text=_("Estimated Time of Arrival"), + verbose_name=_("ETA"), + ) + expiry_date = models.DateField( + blank=True, + null=True, + help_text=_("Date after which the security measure is no longer valid"), + verbose_name=_("Expiry date"), + ) + link = models.CharField( + null=True, + blank=True, + max_length=1000, + help_text=_("External url for action follow-up (eg. Jira ticket)"), + verbose_name=_("Link"), + ) + effort = models.CharField( + null=True, + blank=True, + max_length=2, + choices=EFFORT, + help_text=_("Relative effort of the measure (using T-Shirt sizing)"), + verbose_name=_("Effort"), + ) + + fields_to_check = ["name", "category"] + + class Meta: + verbose_name = _("Security measure") + verbose_name_plural = _("Security measures") + + def save(self, *args, **kwargs): + if self.security_function and self.category is None: + self.category = self.security_function.category + super(SecurityMeasure, self).save(*args, **kwargs) + + @property + def risk_scenarios(self): + return self.risk_scenarios.all() + + @property + def risk_assessments(self): + return {scenario.risk_assessment for scenario in self.risk_scenarios} + + @property + def projects(self): + return {risk_assessment.project for risk_assessment in self.risk_assessments} + + def parent_project(self): + pass + + def __str__(self): + return self.name + + @property + def mid(self): + return f"M.{self.scoped_id(scope=SecurityMeasure.objects.filter(folder=self.folder))}" + + @property + def csv_value(self): + return f"[{self.status}] {self.name}" if self.status else self.name + + def get_ranking_score(self): + if self.effort: + value = 0 + for risk_scenario in self.risk_scenarios.all(): + current = risk_scenario.current_level + residual = risk_scenario.residual_level + if current >= 0 and residual >= 0: + value += (1 + current - residual) * (current + 1) + return round(value / self.MAP_EFFORT[self.effort], 4) + else: + return 0 + + @property + def get_html_url(self): + url = reverse("securitymeasure-detail", args=(self.id,)) + return format_html( + ' [MT-eta] {}: {} ', + url, + self.folder.name, + self.name, + ) + + def get_linked_requirements_count(self): + return RequirementNode.objects.filter( + requirementassessment__security_measures=self + ).count() + + +class PolicyManager(models.Manager): + def create(self, *args, **kwargs): + kwargs["category"] = "policy" # Ensure category is always "policy" + return super().create(*args, **kwargs) + + +class Policy(SecurityMeasure): + class Meta: + proxy = True + verbose_name = _("Policy") + verbose_name_plural = _("Policies") + + objects = PolicyManager() # Use the custom manager + + def save(self, *args, **kwargs): + self.category = "policy" + super(Policy, self).save(*args, **kwargs) + + +########################### Secondary objects ######################### + + +class Assessment(NameDescriptionMixin): + class Status(models.TextChoices): + PLANNED = "planned", _("Planned") + IN_PROGRESS = "in_progress", _("In progress") + IN_REVIEW = "in_review", _("In review") + DONE = "done", _("Done") + DEPRECATED = "deprecated", _("Deprecated") + + project = models.ForeignKey( + "Project", on_delete=models.CASCADE, verbose_name=_("Project") + ) + version = models.CharField( + max_length=100, + blank=True, + null=True, + help_text=_("Version of the compliance assessment (eg. 1.0, 2.0, etc.)"), + verbose_name=_("Version"), + default="1.0", + ) + status = models.CharField( + max_length=100, + choices=Status.choices, + default=Status.PLANNED, + verbose_name=_("Status"), + ) + authors = models.ManyToManyField( + User, + blank=True, + verbose_name=_("Authors"), + related_name="%(class)s_authors", + ) + reviewers = models.ManyToManyField( + User, + blank=True, + verbose_name=_("Reviewers"), + related_name="%(class)s_reviewers", + ) + eta = models.DateField( + null=True, + blank=True, + help_text=_("Estimated time of arrival"), + verbose_name=_("ETA"), + ) + due_date = models.DateField( + null=True, + blank=True, + help_text=_("Due date"), + verbose_name=_("Due date"), + ) + + fields_to_check = ["name", "version"] + + class Meta: + abstract = True + + +class RiskAssessment(Assessment): + risk_matrix = models.ForeignKey( + RiskMatrix, + on_delete=models.PROTECT, + help_text=_("WARNING! After choosing it, you will not be able to change it"), + verbose_name=_("Risk matrix"), + ) + + class Meta: + verbose_name = _("Risk assessment") + verbose_name_plural = _("Risk assessments") + + def __str__(self) -> str: + return f"{self.project.folder}/{self.project}/{self.name} - {self.version}" + + @property + def path_display(self) -> str: + return f"{self.project.folder}/{self.project}/{self.name} - {self.version}" + + def get_scenario_count(self) -> int: + count = RiskScenario.objects.filter(risk_assessment=self.id).count() + scenario_count = count + return scenario_count + + def quality_check(self) -> dict: + errors_lst = list() + warnings_lst = list() + info_lst = list() + # --- check on the risk risk_assessment: + _object = serializers.serialize("json", [self]) + _object = json.loads(_object) + if self.status == Assessment.Status.IN_PROGRESS: + info_lst.append( + { + "msg": _("{}: Risk assessment is still in progress").format( + str(self) + ), + "obj_type": "risk_assessment", + "object": _object, + } + ) + if not self.authors: + info_lst.append( + { + "msg": _( + "{}: No author assigned to this risk risk assessment" + ).format(str(self)), + "obj_type": "risk_assessment", + "object": _object, + } + ) + if not self.risk_scenarios.all(): + warnings_lst.append( + { + "msg": _( + "{}: RiskAssessment is empty. No risk scenario declared yet" + ).format(self), + "obj_type": "risk_assessment", + "object": _object, + } + ) + # --- + + # --- checks on the risk scenarios + # TODO: Refactor this + _scenarios = serializers.serialize( + "json", self.risk_scenarios.all().order_by("created_at") + ) + scenarios = [x["fields"] for x in json.loads(_scenarios)] + for ri in scenarios: + if ri["current_level"] < 0: + warnings_lst.append( + { + "msg": _("{} current risk level has not been assessed").format( + ri["name"] + ), "obj_type": "riskscenario", "object": ri, } @@ -624,276 +932,56 @@ def quality_check(self) -> dict: ).format(mtg["name"]), "obj_type": "securitymeasure", "object": {"name": mtg["name"], "id": mtg["id"]}, - } - ) - - # --- checks on the risk acceptances - _acceptances = serializers.serialize( - "json", - RiskAcceptance.objects.filter(risk_scenarios__risk_assessment=self) - .distinct() - .order_by("created_at"), - ) - acceptances = [x["fields"] for x in json.loads(_acceptances)] - for ra in acceptances: - if not ra["expiry_date"]: - warnings_lst.append( - { - "msg": _("{}: Acceptance has no expiry date").format( - ra["name"] - ), - "obj_type": "securitymeasure", - "object": ra, - } - ) - continue - if date.today() > datetime.strptime(ra["expiry_date"], "%Y-%m-%d").date(): - errors_lst.append( - { - "msg": _( - "{}: Acceptance has expired. Consider updating the status or the date" - ).format(ra["name"]), - "obj_type": "riskacceptance", - "object": ra, - } - ) - - findings = { - "errors": errors_lst, - "warnings": warnings_lst, - "info": info_lst, - "count": len(errors_lst + warnings_lst + info_lst), - } - return findings - - # NOTE: if your save() method throws an exception, you might want to override the clean() method to prevent - # 500 errors when the form submitted. See https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.clean - - -def risk_scoring(probability, impact, risk_matrix: RiskMatrix) -> int: - fields = json.loads(risk_matrix.json_definition) - risk_index = fields["grid"][probability][impact] - return risk_index - - -class Evidence(NameDescriptionMixin, FolderMixin): - # TODO: Manage file upload to S3/MiniO - attachment = models.FileField( - # upload_to=settings.LOCAL_STORAGE_DIRECTORY, - blank=True, - null=True, - help_text=_("Attachment for evidence (eg. screenshot, log file, etc.)"), - verbose_name=_("Attachment"), - validators=[validate_file_size, validate_file_name], - ) - link = models.URLField( - blank=True, - null=True, - help_text=_("Link to the evidence (eg. Jira ticket, etc.)"), - verbose_name=_("Link"), - ) - - class Meta: - verbose_name = _("Evidence") - verbose_name_plural = _("Evidences") - - def get_folder(self): - if self.security_measures: - return self.security_measures.first().folder - elif self.requirement_assessments: - return self.requirement_assessments.first().folder - else: - return None - - def filename(self): - return os.path.basename(self.attachment.name) - - def preview(self): - if self.attachment: - if self.filename().endswith((".png", ".jpg", ".jpeg")): - return ( - "image", - mark_safe(''.format(self.attachment.url)), - ) - if self.filename().endswith(".txt"): - with open(self.attachment.path, "r") as text: - return ("text", text.read()) - if self.filename().endswith(".pdf"): - return ( - "pdf", - mark_safe( - ''.format( - self.attachment.url - ) - ), - ) - if self.filename().endswith(".docx"): - return ( - "icon", - mark_safe(''.format("/static/icons/word.png")), - ) - if self.filename().endswith((".xls", ".xlsx", ".csv")): - return ( - "icon", - mark_safe(''.format("/static/icons/excel.png")), - ) - return "" - - -class SecurityMeasure(NameDescriptionMixin, FolderMixin): - class Status(models.TextChoices): - PLANNED = "planned", _("Planned") - ACTIVE = "active", _("Active") - INACTIVE = "inactive", _("Inactive") - - CATEGORY = SecurityFunction.CATEGORY - - EFFORT = [ - ("S", _("Small")), - ("M", _("Medium")), - ("L", _("Large")), - ("XL", _("Extra-Large")), - ] - - MAP_EFFORT = {None: -1, "S": 1, "M": 2, "L": 4, "XL": 8} - # todo: think about a smarter model for ranking - security_function = models.ForeignKey( - SecurityFunction, - on_delete=models.CASCADE, - null=True, - blank=True, - verbose_name=_("Security Function"), - ) - evidences = models.ManyToManyField( - Evidence, - blank=True, - verbose_name=_("Evidences"), - related_name="security_measures", - ) - category = models.CharField( - max_length=20, - choices=CATEGORY, - null=True, - blank=True, - verbose_name=_("Category"), - ) - status = models.CharField( - max_length=20, - choices=Status.choices, - null=True, - blank=True, - verbose_name=_("Status"), - ) - eta = models.DateField( - blank=True, - null=True, - help_text=_("Estimated Time of Arrival"), - verbose_name=_("ETA"), - ) - expiry_date = models.DateField( - blank=True, - null=True, - help_text=_("Date after which the security measure is no longer valid"), - verbose_name=_("Expiry date"), - ) - link = models.CharField( - null=True, - blank=True, - max_length=1000, - help_text=_("External url for action follow-up (eg. Jira ticket)"), - verbose_name=_("Link"), - ) - effort = models.CharField( - null=True, - blank=True, - max_length=2, - choices=EFFORT, - help_text=_("Relative effort of the measure (using T-Shirt sizing)"), - verbose_name=_("Effort"), - ) - - fields_to_check = ["name", "category"] - - class Meta: - verbose_name = _("Security measure") - verbose_name_plural = _("Security measures") - - def save(self, *args, **kwargs): - if self.security_function and self.category is None: - self.category = self.security_function.category - super(SecurityMeasure, self).save(*args, **kwargs) - - @property - def risk_scenarios(self): - return self.risk_scenarios.all() - - @property - def risk_assessments(self): - return {scenario.risk_assessment for scenario in self.risk_scenarios} - - @property - def projects(self): - return {risk_assessment.project for risk_assessment in self.risk_assessments} - - def parent_project(self): - pass - - def __str__(self): - return self.name - - @property - def mid(self): - return f"M.{self.scoped_id(scope=SecurityMeasure.objects.filter(folder=self.folder))}" - - @property - def csv_value(self): - return f"[{self.status}] {self.name}" if self.status else self.name - - def get_ranking_score(self): - if self.effort: - value = 0 - for risk_scenario in self.risk_scenarios.all(): - current = risk_scenario.current_level - residual = risk_scenario.residual_level - if current >= 0 and residual >= 0: - value += (1 + current - residual) * (current + 1) - return round(value / self.MAP_EFFORT[self.effort], 4) - else: - return 0 - - @property - def get_html_url(self): - url = reverse("securitymeasure-detail", args=(self.id,)) - return format_html( - ' [MT-eta] {}: {} ', - url, - self.folder.name, - self.name, - ) - - def get_linked_requirements_count(self): - return RequirementNode.objects.filter( - requirementassessment__security_measures=self - ).count() - + } + ) -class PolicyManager(models.Manager): - def create(self, *args, **kwargs): - kwargs["category"] = "policy" # Ensure category is always "policy" - return super().create(*args, **kwargs) + # --- checks on the risk acceptances + _acceptances = serializers.serialize( + "json", + RiskAcceptance.objects.filter(risk_scenarios__risk_assessment=self) + .distinct() + .order_by("created_at"), + ) + acceptances = [x["fields"] for x in json.loads(_acceptances)] + for ra in acceptances: + if not ra["expiry_date"]: + warnings_lst.append( + { + "msg": _("{}: Acceptance has no expiry date").format( + ra["name"] + ), + "obj_type": "securitymeasure", + "object": ra, + } + ) + continue + if date.today() > datetime.strptime(ra["expiry_date"], "%Y-%m-%d").date(): + errors_lst.append( + { + "msg": _( + "{}: Acceptance has expired. Consider updating the status or the date" + ).format(ra["name"]), + "obj_type": "riskacceptance", + "object": ra, + } + ) + findings = { + "errors": errors_lst, + "warnings": warnings_lst, + "info": info_lst, + "count": len(errors_lst + warnings_lst + info_lst), + } + return findings -class Policy(SecurityMeasure): - class Meta: - proxy = True - verbose_name = _("Policy") - verbose_name_plural = _("Policies") + # NOTE: if your save() method throws an exception, you might want to override the clean() method to prevent + # 500 errors when the form submitted. See https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.clean - objects = PolicyManager() # Use the custom manager - def save(self, *args, **kwargs): - self.category = "policy" - super(Policy, self).save(*args, **kwargs) +def risk_scoring(probability, impact, risk_matrix: RiskMatrix) -> int: + fields = json.loads(risk_matrix.json_definition) + risk_index = fields["grid"][probability][impact] + return risk_index class RiskScenario(NameDescriptionMixin): @@ -976,285 +1064,117 @@ class RiskScenario(NameDescriptionMixin): ), ) - treatment = models.CharField( - max_length=20, - choices=TREATMENT_OPTIONS, - default="open", - verbose_name=_("Treatment status"), - ) - - strength_of_knowledge = models.CharField( - max_length=20, - choices=SOK_OPTIONS, - default="--", - verbose_name=_("Strength of Knowledge"), - ) - justification = models.CharField( - max_length=500, blank=True, null=True, verbose_name=_("Justification") - ) - - class Meta: - verbose_name = _("Risk scenario") - verbose_name_plural = _("Risk scenarios") - - # def get_rating_options(self, field: str) -> list[tuple]: - # risk_matrix = self.risk_assessment.risk_matrix.parse_json() - # return [(k, v) for k, v in risk_matrix.fields[field].items()] - - def parent_project(self): - return self.risk_assessment.project - - parent_project.short_description = _("Project") - - def get_matrix(self): - return self.risk_assessment.risk_matrix.parse_json() - - def get_current_risk(self): - if self.current_level < 0: - return { - "abbreviation": "--", - "name": "--", - "description": "not rated", - "hexcolor": "#A9A9A9", - } - risk_matrix = self.get_matrix() - return risk_matrix["risk"][self.current_level] - - def get_current_impact(self): - if self.current_impact < 0: - return {"abbreviation": "--", "name": "--", "description": "not rated"} - risk_matrix = self.get_matrix() - return risk_matrix["impact"][self.current_impact] - - def get_current_proba(self): - if self.current_proba < 0: - return {"abbreviation": "--", "name": "--", "description": "not rated"} - risk_matrix = self.get_matrix() - return risk_matrix["probability"][self.current_proba] - - def get_residual_risk(self): - if self.residual_level < 0: - return { - "abbreviation": "--", - "name": "--", - "description": "not rated", - "hexcolor": "#A9A9A9", - } - risk_matrix = self.get_matrix() - return risk_matrix["risk"][self.residual_level] - - def get_residual_impact(self): - if self.residual_impact < 0: - return {"abbreviation": "--", "name": "--", "description": "not rated"} - risk_matrix = self.get_matrix() - return risk_matrix["impact"][self.residual_impact] - - def get_residual_proba(self): - if self.residual_proba < 0: - return {"abbreviation": "--", "name": "--", "description": "not rated"} - risk_matrix = self.get_matrix() - return risk_matrix["probability"][self.residual_proba] - - def __str__(self): - return ( - str(self.parent_project().folder) - + _("/") - + str(self.parent_project()) - + _(": ") - + str(self.name) - ) - - @property - def rid(self): - return f"R.{self.scoped_id(scope=RiskScenario.objects.filter(risk_assessment=self.risk_assessment))}" - - def save(self, *args, **kwargs): - if self.current_proba >= 0 and self.current_impact >= 0: - self.current_level = risk_scoring( - self.current_proba, - self.current_impact, - self.risk_assessment.risk_matrix, - ) - else: - self.current_level = -1 - if self.residual_proba >= 0 and self.residual_impact >= 0: - self.residual_level = risk_scoring( - self.residual_proba, - self.residual_impact, - self.risk_assessment.risk_matrix, - ) - else: - self.residual_level = -1 - super(RiskScenario, self).save(*args, **kwargs) - - -class RiskAcceptance(NameDescriptionMixin, FolderMixin): - ACCEPTANCE_STATE = [ - ("created", _("Created")), - ("submitted", _("Submitted")), - ("accepted", _("Accepted")), - ("rejected", _("Rejected")), - ("revoked", _("Revoked")), - ] - - risk_scenarios = models.ManyToManyField( - RiskScenario, - verbose_name=_("Risk scenarios"), - help_text=_( - "Select the risk scenarios to be accepted, attention they must be part of the chosen domain" - ), - ) - approver = models.ForeignKey( - User, - max_length=200, - help_text=_("Risk owner and approver identity"), - verbose_name=_("Approver"), - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - state = models.CharField( - max_length=20, - choices=ACCEPTANCE_STATE, - default="created", - verbose_name=_("State"), - ) - expiry_date = models.DateField( - help_text=_("Specify when the risk acceptance will no longer apply"), - null=True, - verbose_name=_("Expiry date"), - ) - accepted_at = models.DateTimeField( - blank=True, null=True, verbose_name=_("Acceptance date") - ) - rejected_at = models.DateTimeField( - blank=True, null=True, verbose_name=_("Rejection date") - ) - revoked_at = models.DateTimeField( - blank=True, null=True, verbose_name=_("Revocation date") - ) - justification = models.CharField( - max_length=500, blank=True, null=True, verbose_name=_("Justification") - ) - - fields_to_check = ["name"] - - class Meta: - permissions = [ - ("approve_riskacceptance", "Can validate/rejected risk acceptances") - ] - verbose_name = _("Risk acceptance") - verbose_name_plural = _("Risk acceptances") - - def __str__(self): - if self.name: - return self.name - scenario_names: str = ", ".join( - [str(scenario) for scenario in self.risk_scenarios.all()] - ) - return f"{scenario_names}" - - @property - def get_html_url(self): - url = reverse("riskacceptance-detail", args=(self.id,)) - return format_html( - ' [RA-exp] {}: {} ', - url, - self.folder.name, - self.name, - ) - - def set_state(self, state): - self.state = state - if state == "accepted": - self.accepted_at = datetime.now() - if state == "rejected": - self.rejected_at = datetime.now() - elif state == "revoked": - self.revoked_at = datetime.now() - self.save() - + treatment = models.CharField( + max_length=20, + choices=TREATMENT_OPTIONS, + default="open", + verbose_name=_("Treatment status"), + ) -class Framework(ReferentialObjectMixin): - library = models.ForeignKey( - Library, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="frameworks", + strength_of_knowledge = models.CharField( + max_length=20, + choices=SOK_OPTIONS, + default="--", + verbose_name=_("Strength of Knowledge"), + ) + justification = models.CharField( + max_length=500, blank=True, null=True, verbose_name=_("Justification") ) class Meta: - verbose_name = _("Framework") - verbose_name_plural = _("Frameworks") + verbose_name = _("Risk scenario") + verbose_name_plural = _("Risk scenarios") - def get_next_order_id(self, obj_type: models.Model, _parent_urn: str = None) -> int: - """ - Returns the next order id for a given object type - """ - if _parent_urn: - return ( - obj_type.objects.filter(framework=self, parent_urn=_parent_urn).count() - + 1 - ) - else: - return obj_type.objects.filter(framework=self).count() + 1 + # def get_rating_options(self, field: str) -> list[tuple]: + # risk_matrix = self.risk_assessment.risk_matrix.parse_json() + # return [(k, v) for k, v in risk_matrix.fields[field].items()] - def is_deletable(self) -> bool: - """ - Returns True if the framework can be deleted - """ - if self.compliance_assessment_set.count() > 0: - return False - return True + def parent_project(self): + return self.risk_assessment.project + parent_project.short_description = _("Project") -class RequirementLevel(ReferentialObjectMixin): - framework = models.ForeignKey( - Framework, - on_delete=models.CASCADE, - null=True, - blank=True, - verbose_name=_("Framework"), - ) - level = models.IntegerField(null=False, blank=False, verbose_name=_("Level")) + def get_matrix(self): + return self.risk_assessment.risk_matrix.parse_json() - class Meta: - verbose_name = _("Requirements level") - verbose_name_plural = _("Requirements levels") + def get_current_risk(self): + if self.current_level < 0: + return { + "abbreviation": "--", + "name": "--", + "description": "not rated", + "hexcolor": "#A9A9A9", + } + risk_matrix = self.get_matrix() + return risk_matrix["risk"][self.current_level] + def get_current_impact(self): + if self.current_impact < 0: + return {"abbreviation": "--", "name": "--", "description": "not rated"} + risk_matrix = self.get_matrix() + return risk_matrix["impact"][self.current_impact] -class RequirementNode(ReferentialObjectMixin): - threats = models.ManyToManyField( - "Threat", - blank=True, - verbose_name=_("Threats"), - related_name="requirements", - ) - security_functions = models.ManyToManyField( - "SecurityFunction", - blank=True, - verbose_name=_("Security functions"), - related_name="requirements", - ) - framework = models.ForeignKey( - Framework, - on_delete=models.CASCADE, - null=True, - blank=True, - verbose_name=_("Framework"), - ) - parent_urn = models.CharField( - max_length=100, null=True, blank=True, verbose_name=_("Parent URN") - ) - order_id = models.IntegerField(null=True, verbose_name=_("Order ID")) - level = models.IntegerField(null=True, verbose_name=_("Level")) - maturity = models.IntegerField(null=True, verbose_name=_("Maturity")) - assessable = models.BooleanField(null=False, verbose_name=_("Assessable")) + def get_current_proba(self): + if self.current_proba < 0: + return {"abbreviation": "--", "name": "--", "description": "not rated"} + risk_matrix = self.get_matrix() + return risk_matrix["probability"][self.current_proba] - class Meta: - verbose_name = _("RequirementNode") - verbose_name_plural = _("RequirementNodes") + def get_residual_risk(self): + if self.residual_level < 0: + return { + "abbreviation": "--", + "name": "--", + "description": "not rated", + "hexcolor": "#A9A9A9", + } + risk_matrix = self.get_matrix() + return risk_matrix["risk"][self.residual_level] + + def get_residual_impact(self): + if self.residual_impact < 0: + return {"abbreviation": "--", "name": "--", "description": "not rated"} + risk_matrix = self.get_matrix() + return risk_matrix["impact"][self.residual_impact] + + def get_residual_proba(self): + if self.residual_proba < 0: + return {"abbreviation": "--", "name": "--", "description": "not rated"} + risk_matrix = self.get_matrix() + return risk_matrix["probability"][self.residual_proba] + + def __str__(self): + return ( + str(self.parent_project().folder) + + _("/") + + str(self.parent_project()) + + _(": ") + + str(self.name) + ) + + @property + def rid(self): + """return associated risk assessment id""" + return f"R.{self.scoped_id(scope=RiskScenario.objects.filter(risk_assessment=self.risk_assessment))}" + + def save(self, *args, **kwargs): + if self.current_proba >= 0 and self.current_impact >= 0: + self.current_level = risk_scoring( + self.current_proba, + self.current_impact, + self.risk_assessment.risk_matrix, + ) + else: + self.current_level = -1 + if self.residual_proba >= 0 and self.residual_impact >= 0: + self.residual_level = risk_scoring( + self.residual_proba, + self.residual_impact, + self.risk_assessment.risk_matrix, + ) + else: + self.residual_level = -1 + super(RiskScenario, self).save(*args, **kwargs) class ComplianceAssessment(Assessment): @@ -1485,3 +1405,94 @@ def __str__(self) -> str: class Meta: verbose_name = _("Requirement assessment") verbose_name_plural = _("Requirement assessments") + + +########################### RiskAcesptance is a domain object relying on secondary objects ######################### + + +class RiskAcceptance(NameDescriptionMixin, FolderMixin): + ACCEPTANCE_STATE = [ + ("created", _("Created")), + ("submitted", _("Submitted")), + ("accepted", _("Accepted")), + ("rejected", _("Rejected")), + ("revoked", _("Revoked")), + ] + + risk_scenarios = models.ManyToManyField( + RiskScenario, + verbose_name=_("Risk scenarios"), + help_text=_( + "Select the risk scenarios to be accepted, attention they must be part of the chosen domain" + ), + ) + approver = models.ForeignKey( + User, + max_length=200, + help_text=_("Risk owner and approver identity"), + verbose_name=_("Approver"), + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + state = models.CharField( + max_length=20, + choices=ACCEPTANCE_STATE, + default="created", + verbose_name=_("State"), + ) + expiry_date = models.DateField( + help_text=_("Specify when the risk acceptance will no longer apply"), + null=True, + verbose_name=_("Expiry date"), + ) + accepted_at = models.DateTimeField( + blank=True, null=True, verbose_name=_("Acceptance date") + ) + rejected_at = models.DateTimeField( + blank=True, null=True, verbose_name=_("Rejection date") + ) + revoked_at = models.DateTimeField( + blank=True, null=True, verbose_name=_("Revocation date") + ) + justification = models.CharField( + max_length=500, blank=True, null=True, verbose_name=_("Justification") + ) + + fields_to_check = ["name"] + + class Meta: + permissions = [ + ("approve_riskacceptance", "Can validate/rejected risk acceptances") + ] + verbose_name = _("Risk acceptance") + verbose_name_plural = _("Risk acceptances") + + def __str__(self): + if self.name: + return self.name + scenario_names: str = ", ".join( + [str(scenario) for scenario in self.risk_scenarios.all()] + ) + return f"{scenario_names}" + + @property + def get_html_url(self): + url = reverse("riskacceptance-detail", args=(self.id,)) + return format_html( + ' [RA-exp] {}: {} ', + url, + self.folder.name, + self.name, + ) + + def set_state(self, state): + self.state = state + if state == "accepted": + self.accepted_at = datetime.now() + if state == "rejected": + self.rejected_at = datetime.now() + elif state == "revoked": + self.revoked_at = datetime.now() + self.save() + From 345204e4e047a7ec237901cea06fda01a6189653 Mon Sep 17 00:00:00 2001 From: eric-intuitem <71850047+eric-intuitem@users.noreply.github.com> Date: Sun, 25 Feb 2024 18:49:09 +0100 Subject: [PATCH 4/9] Update VERSION --- backend/ciso_assistant/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ciso_assistant/VERSION b/backend/ciso_assistant/VERSION index e3e180701e..7e310bae19 100644 --- a/backend/ciso_assistant/VERSION +++ b/backend/ciso_assistant/VERSION @@ -1 +1 @@ -0.9.8 +0.9.9 From 56696ff9e485eba17937cf1c667adaa61c9d1067 Mon Sep 17 00:00:00 2001 From: monsieurswag Date: Wed, 21 Feb 2024 10:04:01 +0100 Subject: [PATCH 5/9] Make The website work offline by using font-awesome locally --- frontend/package-lock.json | 10 ++++++++++ frontend/package.json | 3 ++- frontend/src/app.html | 8 +------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2d5fccdbd..8a0ef0351e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@floating-ui/dom": "^1.5.1", + "@fortawesome/fontawesome-free": "^6.5.1", "@inlang/paraglide-js-adapter-vite": "^1.2.14", "dotenv": "^16.4.1", "echarts": "^5.4.3", @@ -803,6 +804,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz", + "integrity": "sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==", + "hasInstallScript": true, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index 654f3a9bba..7879ba055a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,8 +18,8 @@ "postinstall": "paraglide-js compile --project ./project.inlang" }, "devDependencies": { - "@playwright/test": "^1.40.1", "@inlang/paraglide-js": "1.2.5", + "@playwright/test": "^1.40.1", "@skeletonlabs/skeleton": "^2.3.0", "@skeletonlabs/tw-plugin": "^0.2.2", "@sveltejs/adapter-auto": "^3.0.0", @@ -61,6 +61,7 @@ "type": "module", "dependencies": { "@floating-ui/dom": "^1.5.1", + "@fortawesome/fontawesome-free": "^6.5.1", "@inlang/paraglide-js-adapter-vite": "^1.2.14", "dotenv": "^16.4.1", "echarts": "^5.4.3", diff --git a/frontend/src/app.html b/frontend/src/app.html index ff6d832aaa..06cf9318ec 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,13 +3,7 @@ - + %sveltekit.head%