diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0478932f7..3de8a6397 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -17,6 +17,7 @@ from dcim.models import ( ) from extras.api.customfields import CustomFieldModelSerializer from ipam.models import IPAddress, VLAN +from secrets.models import Secret from tenancy.api.serializers import NestedTenantSerializer from users.api.serializers import NestedUserSerializer from utilities.api import ( diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 564e6fa74..5aedf39af 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1287,9 +1287,13 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) + secrets = GenericRelation( + to='secrets.Secret' + ) objects = DeviceManager() tags = TaggableManager() + csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f6d1b26fd..93fc80634 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -130,6 +130,8 @@ EMAIL_SUBJECT_PREFIX = '[NetBox] ' # Installed applications INSTALLED_APPS = [ + 'corsheaders', + 'debug_toolbar', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -137,10 +139,9 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', - 'corsheaders', - 'debug_toolbar', 'django_filters', 'django_tables2', + 'drf_yasg', 'mptt', 'rest_framework', 'taggit', @@ -155,7 +156,6 @@ INSTALLED_APPS = [ 'users', 'utilities', 'virtualization', - 'drf_yasg', ] # Only load django-rq if the webhook backend is enabled diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index ee7217b63..587876487 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField - from dcim.api.serializers import NestedDeviceSerializer +from dcim.models import Device from extras.api.customfields import CustomFieldModelSerializer from secrets.models import Secret, SecretRole from utilities.api import ValidatedModelSerializer, WritableNestedSerializer @@ -32,9 +32,19 @@ class NestedSecretRoleSerializer(WritableNestedSerializer): # # Secrets # +class SecretObjectRelatedField(serializers.RelatedField): + """ + GenericRelation object can be extended with different Object types + """ + def to_representation(self, value): + if isinstance(value, Device): + object_serial = NestedDeviceSerializer(value, context=self.context) + dict = object_serial.data.copy() + dict.update({'class': 'device'}) + return dict class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): - device = NestedDeviceSerializer() + object = SecretObjectRelatedField(queryset=Secret.objects.all(), required=False) role = NestedSecretRoleSerializer() plaintext = serializers.CharField() tags = TagListSerializerField(required=False) @@ -42,12 +52,11 @@ class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = Secret fields = [ - 'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'object', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] def validate(self, data): - # Encrypt plaintext data using the master key provided from the view context if data.get('plaintext'): s = Secret(plaintext=data['plaintext']) @@ -57,7 +66,7 @@ class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): # Validate uniqueness of name if one has been provided. if data.get('name'): - validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name')) + validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('object', 'role', 'name')) validator.set_context(self) validator(data) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 9bc52f9f0..96f859d22 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -46,7 +46,7 @@ class SecretRoleViewSet(ModelViewSet): class SecretViewSet(ModelViewSet): queryset = Secret.objects.select_related( - 'device__primary_ip4', 'device__primary_ip6', 'role', + 'role', ).prefetch_related( 'role__users', 'role__groups', ) diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index f43a82b22..e53850df8 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -34,7 +34,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), - label='Device (ID)', + label='Object (ID)', ) device = django_filters.ModelMultipleChoiceFilter( name='device__name', diff --git a/netbox/secrets/migrations/0006_foreign_key_generic.py b/netbox/secrets/migrations/0006_foreign_key_generic.py new file mode 100644 index 000000000..dd16b1448 --- /dev/null +++ b/netbox/secrets/migrations/0006_foreign_key_generic.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.8 on 2018-08-10 15:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('secrets', '0005_change_logging'), + ] + + operations = [ + migrations.AddField( + model_name='secret', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='secret', + name='object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/secrets/migrations/0007_populate_generic_foreign_key.py b/netbox/secrets/migrations/0007_populate_generic_foreign_key.py new file mode 100644 index 000000000..28ec6e85b --- /dev/null +++ b/netbox/secrets/migrations/0007_populate_generic_foreign_key.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.8 on 2018-08-10 15:29 + +from django.db import migrations + +def migrate_device_secret(apps, schema_editor): + """ + Move data from device foreign key into GenericForeignKey + """ + Secret = apps.get_model('secrets', 'Secret') + if Secret.objects.count() > 0: + ContentType = apps.get_model('contenttypes', 'ContentType') + + secret_device_content_type = ContentType.objects.get(app_label='dcim', model='device') + + for secret in Secret.objects.all(): + secret.content_type = secret_device_content_type + secret.object_id = secret.device.pk + secret.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0058_relax_rack_naming_constraints'), + ('secrets', '0006_foreign_key_generic'), + ] + + operations = [ + migrations.RunPython(migrate_device_secret) + ] diff --git a/netbox/secrets/migrations/0008_create_related_object.py b/netbox/secrets/migrations/0008_create_related_object.py new file mode 100644 index 000000000..5e86af595 --- /dev/null +++ b/netbox/secrets/migrations/0008_create_related_object.py @@ -0,0 +1,38 @@ +# Generated by Django 2.0.8 on 2018-08-10 16:37 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('secrets', '0007_populate_generic_foreign_key'), + ] + + operations = [ + migrations.AlterModelOptions( + name='secret', + options={'ordering': ['content_type', 'object_id', 'role', 'name']}, + ), + migrations.AlterField( + model_name='secret', + name='content_type', + field=models.ForeignKey(default=33, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='secret', + name='object_id', + field=models.PositiveIntegerField(default=1), + ), + migrations.AlterUniqueTogether( + name='secret', + unique_together={('content_type', 'object_id', 'role', 'name')}, + ), + migrations.RemoveField( + model_name='secret', + name='device', + ), + + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 8bbf3d14d..49c60351f 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -8,7 +8,9 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import make_password, check_password from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -322,11 +324,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel): A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis. """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='secrets' - ) role = models.ForeignKey( to='secrets.SecretRole', on_delete=models.PROTECT, @@ -350,38 +347,60 @@ class Secret(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + default= ContentType.objects.get(app_label='dcim', model='device').pk + ) + object_id = models.PositiveIntegerField(default=1) + object = GenericForeignKey('content_type', 'object_id') + tags = TaggableManager() plaintext = None - csv_headers = ['device', 'role', 'name', 'plaintext'] + csv_headers = ['object_id', 'content_type', 'role', 'name', 'plaintext'] class Meta: - ordering = ['device', 'role', 'name'] - unique_together = ['device', 'role', 'name'] + ordering = ['content_type', 'object_id', 'role', 'name'] + unique_together = ['content_type', 'object_id', 'role', 'name'] def __init__(self, *args, **kwargs): self.plaintext = kwargs.pop('plaintext', None) super(Secret, self).__init__(*args, **kwargs) def __str__(self): - if self.role and self.device and self.name: - return '{} for {} ({})'.format(self.role, self.device, self.name) + if self.role and self.name and self.object: + return '{} for {} ({})'.format(self.role, self.object, self.name) # Return role and device if no name is set - if self.role and self.device: - return '{} for {}'.format(self.role, self.device) + if self.role: + return '{} for {}'.format(self.role, self.object) return 'Secret' + def get_device_secrets(device): + """ + Returns Secrets given a device + """ + return Secret.objects.filter( + content_type=ContentType.objects.get( + app_label='dcim', model='device' + ), + object_id=device.pk + ) + def get_absolute_url(self): return reverse('secrets:secret', args=[self.pk]) def to_csv(self): return ( - self.device, + self.object, self.role, self.name, self.plaintext or '', ) + def object_label(self): + return(str(self.content_type).capitalize()) + def _pad(self, s): """ Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B). diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 4cfb1a6ea..12c4e5530 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -39,8 +39,8 @@ class SecretRoleTable(BaseTable): class SecretTable(BaseTable): pk = ToggleColumn() - device = tables.LinkColumn() + object = tables.LinkColumn() class Meta(BaseTable.Meta): model = Secret - fields = ('pk', 'device', 'role', 'name', 'last_updated') + fields = ('pk', 'object', 'role', 'name', 'last_updated') diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 985e0ea7f..dc8846d38 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -173,17 +173,17 @@ class SecretTest(APITestCase): self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') self.secret1 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] + object=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] ) self.secret1.encrypt(self.master_key) self.secret1.save() self.secret2 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] + object=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] ) self.secret2.encrypt(self.master_key) self.secret2.save() self.secret3 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] + object=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] ) self.secret3.encrypt(self.master_key) self.secret3.save() @@ -205,7 +205,8 @@ class SecretTest(APITestCase): def test_create_secret(self): data = { - 'device': self.device.pk, + 'object_id': self.device.pk, + 'content_type': '33', 'role': self.secretrole1.pk, 'name': 'Test Secret 4', 'plaintext': 'Secret #4 Plaintext', @@ -226,19 +227,22 @@ class SecretTest(APITestCase): data = [ { - 'device': self.device.pk, + 'object_id': self.device.pk, + 'content_type': '33', 'role': self.secretrole1.pk, 'name': 'Test Secret 4', 'plaintext': 'Secret #4 Plaintext', }, { - 'device': self.device.pk, + 'object_id': self.device.pk, + 'content_type': '33', 'role': self.secretrole1.pk, 'name': 'Test Secret 5', 'plaintext': 'Secret #5 Plaintext', }, { - 'device': self.device.pk, + 'object_id': self.device.pk, + 'content_type': '33', 'role': self.secretrole1.pk, 'name': 'Test Secret 6', 'plaintext': 'Secret #6 Plaintext', @@ -257,7 +261,8 @@ class SecretTest(APITestCase): def test_update_secret(self): data = { - 'device': self.device.pk, + 'object_id': self.device.pk, + 'content_type': '33', 'role': self.secretrole2.pk, 'plaintext': 'NewPlaintext', } diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d15c9cbc2..3b243f4b5 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,10 +1,12 @@ from __future__ import unicode_literals import base64 +import os from django.contrib import messages from django.contrib.auth.decorators import permission_required, login_required from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -71,7 +73,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @method_decorator(login_required, name='dispatch') class SecretListView(ObjectListView): - queryset = Secret.objects.select_related('role', 'device') + queryset = Secret.objects.select_related('role').prefetch_related('object') filter = filters.SecretFilter filter_form = forms.SecretFilterForm table = tables.SecretTable @@ -93,11 +95,16 @@ class SecretView(View): @permission_required('secrets.add_secret') @userkey_required() def secret_add(request, pk): + path = request.path + path = os.path.normpath(path) + object = path.split(os.sep)[2] + secret = { + 'devices': lambda pk: Secret( + object_id=pk, + content_type=ContentType.objects.get(app_label='dcim',model='device') + ) + }[object](pk) - # Retrieve device - device = get_object_or_404(Device, pk=pk) - - secret = Secret(device=device) session_key = get_session_key(request) if request.method == 'POST': @@ -131,10 +138,14 @@ def secret_add(request, pk): else: form = forms.SecretForm(instance=secret) + return_url = { + 'devices': Device.objects.get(pk=pk).get_absolute_url() + }[object] + return render(request, 'secrets/secret_edit.html', { 'secret': secret, 'form': form, - 'return_url': device.get_absolute_url(), + 'return_url': return_url, }) @@ -247,7 +258,7 @@ class SecretBulkImportView(BulkImportView): class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'secrets.change_secret' - queryset = Secret.objects.select_related('role', 'device') + queryset = Secret.objects.select_related('role').prefetch_related('object') filter = filters.SecretFilter table = tables.SecretTable form = forms.SecretBulkEditForm @@ -256,7 +267,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secret' - queryset = Secret.objects.select_related('role', 'device') + queryset = Secret.objects.select_related('role').prefetch_related('object') filter = filters.SecretFilter table = tables.SecretTable default_return_url = 'secrets:secret_list' diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 940a87157..64c2a596b 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -9,7 +9,7 @@ @@ -48,9 +48,9 @@ - + diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 2d2fc4644..524c61306 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -21,9 +21,9 @@
Secret Attributes
- +
-

{{ secret.device }}

+

{{ secret.object }}

{% render_field form.role %}
Device{{secret.object_label}} - {{ secret.device }} + {{ secret.object }}