Browse Source

Merge pull request #5151 from netbox-community/1503-secret-assignment

#1503: Extend secrets assignment to virtual machines
Jeremy Stretch 5 years ago
parent
commit
2b689239ae

+ 2 - 0
docs/release-notes/version-2.10.md

@@ -6,6 +6,7 @@
 
 
 ### New Features
 ### New Features
 
 
+* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines
 * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI
 * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI
 * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services
 * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services
 * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view
 * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view
@@ -30,3 +31,4 @@
 * extras.ExportTemplate: The `template_language` field has been removed
 * extras.ExportTemplate: The `template_language` field has been removed
 * extras.Graph: This API endpoint has been removed (see #4349)
 * extras.Graph: This API endpoint has been removed (see #4349)
 * ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers
 * ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers
+* secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`.

+ 6 - 0
netbox/dcim/models/devices.py

@@ -582,6 +582,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
+    secrets = GenericRelation(
+        to='secrets.Secret',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='device'
+    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 16 - 5
netbox/secrets/api/serializers.py

@@ -1,10 +1,12 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from dcim.api.nested_serializers import NestedDeviceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from extras.api.serializers import TaggedObjectSerializer
+from secrets.constants import SECRET_ASSIGNMENT_MODELS
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
-from utilities.api import ValidatedModelSerializer
+from utilities.api import ContentTypeField, ValidatedModelSerializer, get_serializer_for_model
 from .nested_serializers import *
 from .nested_serializers import *
 
 
 
 
@@ -23,18 +25,27 @@ class SecretRoleSerializer(ValidatedModelSerializer):
 
 
 class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
 class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail')
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail')
-    device = NestedDeviceSerializer()
+    assigned_object_type = ContentTypeField(
+        queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS)
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
     role = NestedSecretRoleSerializer()
     role = NestedSecretRoleSerializer()
     plaintext = serializers.CharField()
     plaintext = serializers.CharField()
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret
         fields = [
         fields = [
-            'id', 'url', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created',
-            'last_updated',
+            'id', 'url', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'role', 'name', 'plaintext',
+            'hash', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         validators = []
         validators = []
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    def get_assigned_object(self, obj):
+        serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(obj.assigned_object, context=context).data
+
     def validate(self, data):
     def validate(self, data):
 
 
         # Encrypt plaintext data using the master key provided from the view context
         # Encrypt plaintext data using the master key provided from the view context

+ 1 - 3
netbox/secrets/api/views.py

@@ -46,9 +46,7 @@ class SecretRoleViewSet(ModelViewSet):
 #
 #
 
 
 class SecretViewSet(ModelViewSet):
 class SecretViewSet(ModelViewSet):
-    queryset = Secret.objects.prefetch_related(
-        'device__primary_ip4', 'device__primary_ip6', 'role', 'tags',
-    )
+    queryset = Secret.objects.prefetch_related('role', 'tags')
     serializer_class = serializers.SecretSerializer
     serializer_class = serializers.SecretSerializer
     filterset_class = filters.SecretFilterSet
     filterset_class = filters.SecretFilterSet
 
 

+ 8 - 0
netbox/secrets/constants.py

@@ -1,5 +1,13 @@
+from django.db.models import Q
+
+
 #
 #
 # Secrets
 # Secrets
 #
 #
 
 
+SECRET_ASSIGNMENT_MODELS = Q(
+    Q(app_label='dcim', model='device') |
+    Q(app_label='virtualization', model='virtualmachine')
+)
+
 SECRET_PLAINTEXT_MAX_LENGTH = 65535
 SECRET_PLAINTEXT_MAX_LENGTH = 65535

+ 17 - 4
netbox/secrets/filters.py

@@ -4,6 +4,7 @@ from django.db.models import Q
 from dcim.models import Device
 from dcim.models import Device
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
 from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
+from virtualization.models import VirtualMachine
 from .models import Secret, SecretRole
 from .models import Secret, SecretRole
 
 
 
 
@@ -35,16 +36,28 @@ class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
     device = django_filters.ModelMultipleChoiceFilter(
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='device__name',
         field_name='device__name',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
         label='Device (name)',
         label='Device (name)',
     )
     )
+    device_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device',
+        queryset=Device.objects.all(),
+        label='Device (ID)',
+    )
+    virtual_machine = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_machine__name',
+        queryset=VirtualMachine.objects.all(),
+        to_field_name='name',
+        label='Virtual machine (name)',
+    )
+    virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_machine',
+        queryset=VirtualMachine.objects.all(),
+        label='Virtual machine (ID)',
+    )
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:

+ 35 - 14
netbox/secrets/forms.py

@@ -1,6 +1,7 @@
 from Crypto.Cipher import PKCS1_OAEP
 from Crypto.Cipher import PKCS1_OAEP
 from Crypto.PublicKey import RSA
 from Crypto.PublicKey import RSA
 from django import forms
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 
 
 from dcim.models import Device
 from dcim.models import Device
 from extras.forms import (
 from extras.forms import (
@@ -11,6 +12,7 @@ from utilities.forms import (
     BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     SlugField, TagFilterField,
     SlugField, TagFilterField,
 )
 )
+from virtualization.models import VirtualMachine
 from .constants import *
 from .constants import *
 from .models import Secret, SecretRole, UserKey
 from .models import Secret, SecretRole, UserKey
 
 
@@ -64,8 +66,13 @@ class SecretRoleCSVForm(CSVModelForm):
 class SecretForm(BootstrapMixin, CustomFieldModelForm):
 class SecretForm(BootstrapMixin, CustomFieldModelForm):
     device = DynamicModelChoiceField(
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
+        required=False,
         display_field='display_name'
         display_field='display_name'
     )
     )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False
+    )
     plaintext = forms.CharField(
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
         required=False,
@@ -93,10 +100,21 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = Secret
         model = Secret
         fields = [
         fields = [
-            'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
+            'device', 'virtual_machine', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
+
+        # Initialize helper selectors
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {}).copy()
+        if instance:
+            if type(instance.assigned_object) is Device:
+                initial['device'] = instance.assigned_object
+            elif type(instance.assigned_object) is VirtualMachine:
+                initial['virtual_machine'] = instance.assigned_object
+        kwargs['initial'] = initial
+
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # A plaintext value is required when creating a new Secret
         # A plaintext value is required when creating a new Secret
@@ -105,28 +123,31 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
 
 
     def clean(self):
     def clean(self):
 
 
+        if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']:
+            raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.")
+
+        if self.cleaned_data['device'] and self.cleaned_data['virtual_machine']:
+            raise forms.ValidationError("Cannot select both a device and virtual machine for secret assignment.")
+
         # Verify that the provided plaintext values match
         # Verify that the provided plaintext values match
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
             raise forms.ValidationError({
             raise forms.ValidationError({
                 'plaintext2': "The two given plaintext values do not match. Please check your input."
                 'plaintext2': "The two given plaintext values do not match. Please check your input."
             })
             })
 
 
-        # Validate uniqueness
-        if Secret.objects.filter(
-            device=self.cleaned_data['device'],
-            role=self.cleaned_data['role'],
-            name=self.cleaned_data['name']
-        ).exclude(pk=self.instance.pk).exists():
-            raise forms.ValidationError(
-                "Each secret assigned to a device must have a unique combination of role and name"
-            )
+    def save(self, *args, **kwargs):
+        # Set assigned object
+        self.instance.assigned_object = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
+
+        return super().save(*args, **kwargs)
 
 
 
 
 class SecretCSVForm(CustomFieldModelCSVForm):
 class SecretCSVForm(CustomFieldModelCSVForm):
-    device = CSVModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Assigned device'
+    assigned_object_type = CSVModelChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=SECRET_ASSIGNMENT_MODELS,
+        to_field_name='model',
+        help_text='Side A type'
     )
     )
     role = CSVModelChoiceField(
     role = CSVModelChoiceField(
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),

+ 67 - 0
netbox/secrets/migrations/0011_secret_generic_assignments.py

@@ -0,0 +1,67 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def device_to_generic_assignment(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Device = apps.get_model('dcim', 'Device')
+    Secret = apps.get_model('secrets', 'Secret')
+
+    device_ct = ContentType.objects.get_for_model(Device)
+    Secret.objects.update(assigned_object_type=device_ct, assigned_object_id=models.F('device_id'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('secrets', '0010_custom_field_data'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='secret',
+            options={'ordering': ('role', 'name', 'pk')},
+        ),
+
+        # Add assigned_object type & ID fields
+        migrations.AddField(
+            model_name='secret',
+            name='assigned_object_id',
+            field=models.PositiveIntegerField(blank=True, null=True),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='secret',
+            name='assigned_object_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
+            preserve_default=False,
+        ),
+
+        migrations.AlterUniqueTogether(
+            name='secret',
+            unique_together={('assigned_object_type', 'assigned_object_id', 'role', 'name')},
+        ),
+
+        # Copy device assignments and delete device ForeignKey
+        migrations.RunPython(
+            code=device_to_generic_assignment,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RemoveField(
+            model_name='secret',
+            name='device',
+        ),
+
+        # Remove blank/null from assigned_object fields
+        migrations.AlterField(
+            model_name='secret',
+            name='assigned_object_id',
+            field=models.PositiveIntegerField(),
+        ),
+        migrations.AlterField(
+            model_name='secret',
+            name='assigned_object_type',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
+        ),
+    ]

+ 19 - 22
netbox/secrets/models.py

@@ -6,13 +6,14 @@ from Crypto.Util import strxor
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.hashers import make_password, check_password
 from django.contrib.auth.hashers import make_password, check_password
 from django.contrib.auth.models import Group, User
 from django.contrib.auth.models import Group, User
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import force_bytes
 from django.utils.encoding import force_bytes
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from dcim.models import Device
 from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
 from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
@@ -276,17 +277,21 @@ class SecretRole(ChangeLoggedModel):
 class Secret(ChangeLoggedModel, CustomFieldModel):
 class Secret(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
-    SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to a
-    Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the
-    ciphertext; this string is stored as plain text in the database.
+    SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to exactly
+    one NetBox object, and objects may have multiple Secrets associated with them. A name can optionally be defined
+    along with the ciphertext; this string is stored as plain text in the database.
 
 
     A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
     A Secret can be up to 65,535 bytes (64KB - 1B) 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.
     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'
+    assigned_object_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.PROTECT
+    )
+    assigned_object_id = models.PositiveIntegerField()
+    assigned_object = GenericForeignKey(
+        ct_field='assigned_object_type',
+        fk_field='assigned_object_id'
     )
     )
     role = models.ForeignKey(
     role = models.ForeignKey(
         to='secrets.SecretRole',
         to='secrets.SecretRole',
@@ -310,34 +315,26 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()
 
 
     plaintext = None
     plaintext = None
-    csv_headers = ['device', 'role', 'name', 'plaintext']
+    csv_headers = ['assigned_object_type', 'assigned_object_id', 'role', 'name', 'plaintext']
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'role', 'name']
-        unique_together = ['device', 'role', 'name']
+        ordering = ('role', 'name', 'pk')
+        unique_together = ('assigned_object_type', 'assigned_object_id', 'role', 'name')
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         self.plaintext = kwargs.pop('plaintext', None)
         self.plaintext = kwargs.pop('plaintext', None)
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
     def __str__(self):
     def __str__(self):
-        try:
-            device = self.device
-        except Device.DoesNotExist:
-            device = None
-        if self.role and device and self.name:
-            return '{} for {} ({})'.format(self.role, self.device, self.name)
-        # Return role and device if no name is set
-        if self.role and device:
-            return '{} for {}'.format(self.role, self.device)
-        return 'Secret'
+        return self.name or 'Secret'
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('secrets:secret', args=[self.pk])
         return reverse('secrets:secret', args=[self.pk])
 
 
     def to_csv(self):
     def to_csv(self):
         return (
         return (
-            self.device,
+            f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}',
+            self.assigned_object_id,
             self.role,
             self.role,
             self.name,
             self.name,
             self.plaintext or '',
             self.plaintext or '',

+ 6 - 3
netbox/secrets/tables.py

@@ -28,12 +28,15 @@ class SecretRoleTable(BaseTable):
 
 
 class SecretTable(BaseTable):
 class SecretTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    device = tables.LinkColumn()
+    assigned_object = tables.Column(
+        linkify=True,
+        verbose_name='Assigned object'
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='secrets:secret_list'
         url_name='secrets:secret_list'
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Secret
         model = Secret
-        fields = ('pk', 'device', 'role', 'name', 'last_updated', 'hash', 'tags')
-        default_columns = ('pk', 'device', 'role', 'name', 'last_updated')
+        fields = ('pk', 'assigned_object', 'role', 'name', 'last_updated', 'hash', 'tags')
+        default_columns = ('pk', 'assigned_object', 'role', 'name', 'last_updated')

+ 9 - 6
netbox/secrets/tests/test_api.py

@@ -80,9 +80,9 @@ class SecretTest(APIViewTestCases.APIViewTestCase):
         SecretRole.objects.bulk_create(secret_roles)
         SecretRole.objects.bulk_create(secret_roles)
 
 
         secrets = (
         secrets = (
-            Secret(device=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'),
-            Secret(device=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'),
-            Secret(device=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'),
+            Secret(assigned_object=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'),
+            Secret(assigned_object=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'),
+            Secret(assigned_object=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'),
         )
         )
         for secret in secrets:
         for secret in secrets:
             secret.encrypt(self.master_key)
             secret.encrypt(self.master_key)
@@ -90,19 +90,22 @@ class SecretTest(APIViewTestCases.APIViewTestCase):
 
 
         self.create_data = [
         self.create_data = [
             {
             {
-                'device': device.pk,
+                'assigned_object_type': 'dcim.device',
+                'assigned_object_id': device.pk,
                 'role': secret_roles[1].pk,
                 'role': secret_roles[1].pk,
                 'name': 'Secret 4',
                 'name': 'Secret 4',
                 'plaintext': 'JKL',
                 'plaintext': 'JKL',
             },
             },
             {
             {
-                'device': device.pk,
+                'assigned_object_type': 'dcim.device',
+                'assigned_object_id': device.pk,
                 'role': secret_roles[1].pk,
                 'role': secret_roles[1].pk,
                 'name': 'Secret 5',
                 'name': 'Secret 5',
                 'plaintext': 'MNO',
                 'plaintext': 'MNO',
             },
             },
             {
             {
-                'device': device.pk,
+                'assigned_object_type': 'dcim.device',
+                'assigned_object_id': device.pk,
                 'role': secret_roles[1].pk,
                 'role': secret_roles[1].pk,
                 'name': 'Secret 6',
                 'name': 'Secret 6',
                 'plaintext': 'PQR',
                 'plaintext': 'PQR',

+ 25 - 5
netbox/secrets/tests/test_filters.py

@@ -3,6 +3,7 @@ from django.test import TestCase
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.filters import *
 from secrets.filters import *
 from secrets.models import Secret, SecretRole
 from secrets.models import Secret, SecretRole
+from virtualization.models import Cluster, ClusterType, VirtualMachine
 
 
 
 
 class SecretRoleTestCase(TestCase):
 class SecretRoleTestCase(TestCase):
@@ -51,6 +52,15 @@ class SecretTestCase(TestCase):
         )
         )
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
+        cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
+        virtual_machines = (
+            VirtualMachine(name='Virtual Machine 1', cluster=cluster),
+            VirtualMachine(name='Virtual Machine 2', cluster=cluster),
+            VirtualMachine(name='Virtual Machine 3', cluster=cluster),
+        )
+        VirtualMachine.objects.bulk_create(virtual_machines)
+
         roles = (
         roles = (
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
             SecretRole(name='Secret Role 2', slug='secret-role-2'),
             SecretRole(name='Secret Role 2', slug='secret-role-2'),
@@ -59,9 +69,12 @@ class SecretTestCase(TestCase):
         SecretRole.objects.bulk_create(roles)
         SecretRole.objects.bulk_create(roles)
 
 
         secrets = (
         secrets = (
-            Secret(device=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'),
-            Secret(device=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'),
-            Secret(device=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'),
+            Secret(assigned_object=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'),
+            Secret(assigned_object=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'),
+            Secret(assigned_object=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'),
+            Secret(assigned_object=virtual_machines[0], role=roles[0], name='Secret 4', plaintext='SECRET DATA'),
+            Secret(assigned_object=virtual_machines[1], role=roles[1], name='Secret 5', plaintext='SECRET DATA'),
+            Secret(assigned_object=virtual_machines[2], role=roles[2], name='Secret 6', plaintext='SECRET DATA'),
         )
         )
         # Must call save() to encrypt Secrets
         # Must call save() to encrypt Secrets
         for s in secrets:
         for s in secrets:
@@ -78,9 +91,9 @@ class SecretTestCase(TestCase):
     def test_role(self):
     def test_role(self):
         roles = SecretRole.objects.all()[:2]
         roles = SecretRole.objects.all()[:2]
         params = {'role_id': [roles[0].pk, roles[1].pk]}
         params = {'role_id': [roles[0].pk, roles[1].pk]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         params = {'role': [roles[0].slug, roles[1].slug]}
         params = {'role': [roles[0].slug, roles[1].slug]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
     def test_device(self):
     def test_device(self):
         devices = Device.objects.all()[:2]
         devices = Device.objects.all()[:2]
@@ -88,3 +101,10 @@ class SecretTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'device': [devices[0].name, devices[1].name]}
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_virtual_machine(self):
+        virtual_machines = VirtualMachine.objects.all()[:2]
+        params = {'virtual_machine_id': [virtual_machines[0].pk, virtual_machines[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'virtual_machine': [virtual_machines[0].name, virtual_machines[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 10 - 8
netbox/secrets/tests/test_views.py

@@ -69,13 +69,14 @@ class SecretTestCase(
 
 
         # Create one secret per device to allow bulk-editing of names (which must be unique per device/role)
         # Create one secret per device to allow bulk-editing of names (which must be unique per device/role)
         Secret.objects.bulk_create((
         Secret.objects.bulk_create((
-            Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
-            Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
-            Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
+            Secret(assigned_object=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
+            Secret(assigned_object=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
+            Secret(assigned_object=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
         ))
         ))
 
 
         cls.form_data = {
         cls.form_data = {
-            'device': devices[1].pk,
+            'assigned_object_type': 'dcim.device',
+            'assigned_object_id': devices[1].pk,
             'role': secretroles[1].pk,
             'role': secretroles[1].pk,
             'name': 'Secret X',
             'name': 'Secret X',
         }
         }
@@ -100,11 +101,12 @@ class SecretTestCase(
     def test_import_objects(self):
     def test_import_objects(self):
         self.add_permissions('secrets.add_secret')
         self.add_permissions('secrets.add_secret')
 
 
+        device = Device.objects.get(name='Device 1')
         csv_data = (
         csv_data = (
-            "device,role,name,plaintext",
-            "Device 1,Secret Role 1,Secret 4,abcdefghij",
-            "Device 1,Secret Role 1,Secret 5,abcdefghij",
-            "Device 1,Secret Role 1,Secret 6,abcdefghij",
+            "assigned_object_type,assigned_object_id,role,name,plaintext",
+            f"device,{device.pk},Secret Role 1,Secret 4,abcdefghij",
+            f"device,{device.pk},Secret Role 1,Secret 5,abcdefghij",
+            f"device,{device.pk},Secret Role 1,Secret 6,abcdefghij",
         )
         )
 
 
         # Set the session_key cookie on the request
         # Set the session_key cookie on the request

+ 3 - 3
netbox/secrets/views.py

@@ -58,7 +58,7 @@ class SecretRoleBulkDeleteView(BulkDeleteView):
 #
 #
 
 
 class SecretListView(ObjectListView):
 class SecretListView(ObjectListView):
-    queryset = Secret.objects.prefetch_related('role', 'device')
+    queryset = Secret.objects.prefetch_related('role', 'tags')
     filterset = filters.SecretFilterSet
     filterset = filters.SecretFilterSet
     filterset_form = forms.SecretFilterForm
     filterset_form = forms.SecretFilterForm
     table = tables.SecretTable
     table = tables.SecretTable
@@ -198,13 +198,13 @@ class SecretBulkImportView(BulkImportView):
 
 
 
 
 class SecretBulkEditView(BulkEditView):
 class SecretBulkEditView(BulkEditView):
-    queryset = Secret.objects.prefetch_related('role', 'device')
+    queryset = Secret.objects.prefetch_related('role')
     filterset = filters.SecretFilterSet
     filterset = filters.SecretFilterSet
     table = tables.SecretTable
     table = tables.SecretTable
     form = forms.SecretBulkEditForm
     form = forms.SecretBulkEditForm
 
 
 
 
 class SecretBulkDeleteView(BulkDeleteView):
 class SecretBulkDeleteView(BulkDeleteView):
-    queryset = Secret.objects.prefetch_related('role', 'device')
+    queryset = Secret.objects.prefetch_related('role')
     filterset = filters.SecretFilterSet
     filterset = filters.SecretFilterSet
     table = tables.SecretTable
     table = tables.SecretTable

+ 3 - 17
netbox/templates/dcim/device.html

@@ -395,30 +395,16 @@
                                     </table>
                                     </table>
                                 </div>
                                 </div>
                             {% endif %}
                             {% endif %}
-                            {% if request.user.is_authenticated %}
+                            {% if perms.secrets.view_secret %}
                                 <div class="panel panel-default">
                                 <div class="panel panel-default">
                                     <div class="panel-heading">
                                     <div class="panel-heading">
                                         <strong>Secrets</strong>
                                         <strong>Secrets</strong>
                                     </div>
                                     </div>
-                                    {% if secrets %}
-                                        <table class="table table-hover panel-body">
-                                            {% for secret in secrets %}
-                                                {% include 'secrets/inc/secret_tr.html' %}
-                                            {% endfor %}
-                                        </table>
-                                    {% else %}
-                                        <div class="panel-body text-muted">
-                                            None found
-                                        </div>
-                                    {% endif %}
+                                    {% include 'secrets/inc/assigned_secrets.html' %}
                                     {% if perms.secrets.add_secret %}
                                     {% if perms.secrets.add_secret %}
-                                        <form id="secret_form">
-                                            {% csrf_token %}
-                                        </form>
                                         <div class="panel-footer text-right noprint">
                                         <div class="panel-footer text-right noprint">
                                             <a href="{% url 'secrets:secret_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
                                             <a href="{% url 'secrets:secret_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
-                                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                                                Add secret
+                                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add secret
                                             </a>
                                             </a>
                                         </div>
                                         </div>
                                     {% endif %}
                                     {% endif %}

+ 26 - 0
netbox/templates/secrets/inc/assigned_secrets.html

@@ -0,0 +1,26 @@
+{% if secrets %}
+    <table class="table table-hover panel-body">
+        {% for secret in secrets %}
+            <tr>
+                <td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret.role }}</a></td>
+                <td>{{ secret.name }}</td>
+                <td id="secret_{{ secret.pk }}">********</td>
+                <td class="text-right noprint">
+                    <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
+                        <i class="fa fa-lock"></i> Unlock
+                    </button>
+                    <button class="btn btn-xs btn-default copy-secret collapse" secret-id="{{ secret.pk }}" data-clipboard-target="#secret_{{ secret.pk }}">
+                        <i class="fa fa-copy"></i> Copy
+                    </button>
+                    <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
+                        <i class="fa fa-unlock-alt"></i> Lock
+                    </button>
+                </td>
+            </tr>
+        {% endfor %}
+    </table>
+{% else %}
+    <div class="panel-body text-muted">
+        None found
+    </div>
+{% endif %}

+ 0 - 16
netbox/templates/secrets/inc/secret_tr.html

@@ -1,16 +0,0 @@
-<tr>
-    <td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret.role }}</a></td>
-    <td>{{ secret.name }}</td>
-    <td id="secret_{{ secret.pk }}">********</td>
-    <td class="text-right noprint">
-        <button class="btn btn-xs btn-success unlock-secret" secret-id="{{ secret.pk }}">
-            <i class="fa fa-lock"></i> Unlock
-        </button>
-        <button class="btn btn-xs btn-default copy-secret collapse" secret-id="{{ secret.pk }}" data-clipboard-target="#secret_{{ secret.pk }}">
-            <i class="fa fa-copy"></i> Copy
-        </button>
-        <button class="btn btn-xs btn-danger lock-secret collapse" secret-id="{{ secret.pk }}">
-            <i class="fa fa-unlock-alt"></i> Lock
-        </button>
-    </td>
-</tr>

+ 4 - 3
netbox/templates/secrets/secret.html

@@ -11,7 +11,8 @@
             <ol class="breadcrumb">
             <ol class="breadcrumb">
                 <li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
                 <li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
                 <li><a href="{% url 'secrets:secret_list' %}?role={{ secret.role.slug }}">{{ secret.role }}</a></li>
                 <li><a href="{% url 'secrets:secret_list' %}?role={{ secret.role.slug }}">{{ secret.role }}</a></li>
-                <li>{{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
+                <li><a href="{{ secret.assigned_object.get_absolute_url }}">{{ secret.assigned_object }}</a></li>
+                <li>{{ secret }}</li>
             </ol>
             </ol>
         </div>
         </div>
     </div>
     </div>
@@ -50,9 +51,9 @@
             </div>
             </div>
             <table class="table table-hover panel-body">
             <table class="table table-hover panel-body">
                 <tr>
                 <tr>
-                    <td>Device</td>
+                    <td>Assigned object</td>
                     <td>
                     <td>
-                        <a href="{% url 'dcim:device' pk=secret.device.pk %}">{{ secret.device }}</a>
+                        <a href="{{ secret.assigned_object.get_absolute_url }}">{{ secret.assigned_object }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>

+ 17 - 2
netbox/templates/secrets/secret_edit.html

@@ -18,9 +18,24 @@
                 </div>
                 </div>
             {% endif %}
             {% endif %}
             <div class="panel panel-default">
             <div class="panel panel-default">
-                <div class="panel-heading"><strong>Secret Attributes</strong></div>
+                <div class="panel-heading">
+                    <strong>Secret Assignment</strong>
+                </div>
                 <div class="panel-body">
                 <div class="panel-body">
-                    {% render_field form.device %}
+                    {% with vm_tab_active=form.initial.virtual_machine %}
+                        <ul class="nav nav-tabs" role="tablist">
+                            <li role="presentation"{% if not vm_tab_active %} class="active"{% endif %}><a href="#device" role="tab" data-toggle="tab">Device</a></li>
+                            <li role="presentation"{% if vm_tab_active %} class="active"{% endif %}><a href="#virtualmachine" role="tab" data-toggle="tab">Virtual Machine</a></li>
+                        </ul>
+                        <div class="tab-content">
+                            <div class="tab-pane{% if not vm_tab_active %} active{% endif %}" id="device">
+                                {% render_field form.device %}
+                            </div>
+                            <div class="tab-pane{% if vm_tab_active %} active{% endif %}" id="virtualmachine">
+                                {% render_field form.virtual_machine %}
+                            </div>
+                        </div>
+                    {% endwith %}
                     {% render_field form.role %}
                     {% render_field form.role %}
                     {% render_field form.name %}
                     {% render_field form.name %}
                     {% render_field form.userkeys %}
                     {% render_field form.userkeys %}

+ 17 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -220,6 +220,21 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
+        {% if perms.secrets.view_secret %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Secrets</strong>
+                </div>
+                {% include 'secrets/inc/assigned_secrets.html' %}
+                {% if perms.secrets.add_secret %}
+                    <div class="panel-footer text-right noprint">
+                        <a href="{% url 'secrets:secret_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-xs btn-primary">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add secret
+                        </a>
+                    </div>
+                {% endif %}
+            </div>
+        {% endif %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Services</strong>
                 <strong>Services</strong>
@@ -325,8 +340,10 @@
         {% endif %}
         {% endif %}
 	</div>
 	</div>
 </div>
 </div>
+{% include 'secrets/inc/private_key_modal.html' %}
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
 <script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
 <script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
+<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
 {% endblock %}
 {% endblock %}

+ 6 - 0
netbox/virtualization/models.py

@@ -270,6 +270,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
+    secrets = GenericRelation(
+        to='secrets.Secret',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='virtual_machine'
+    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     objects = RestrictedQuerySet.as_manager()
     objects = RestrictedQuerySet.as_manager()

+ 9 - 1
netbox/virtualization/views.py

@@ -9,6 +9,7 @@ from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress, Service
 from ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from secrets.models import Secret
 from utilities.utils import get_subquery
 from utilities.utils import get_subquery
 from utilities.views import (
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
@@ -240,23 +241,30 @@ class VirtualMachineView(ObjectView):
     queryset = VirtualMachine.objects.prefetch_related('tenant__group')
     queryset = VirtualMachine.objects.prefetch_related('tenant__group')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
-
         virtualmachine = get_object_or_404(self.queryset, pk=pk)
         virtualmachine = get_object_or_404(self.queryset, pk=pk)
+
+        # Interfaces
         interfaces = VMInterface.objects.restrict(request.user, 'view').filter(
         interfaces = VMInterface.objects.restrict(request.user, 'view').filter(
             virtual_machine=virtualmachine
             virtual_machine=virtualmachine
         ).prefetch_related(
         ).prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user))
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user))
         )
         )
+
+        # Services
         services = Service.objects.restrict(request.user, 'view').filter(
         services = Service.objects.restrict(request.user, 'view').filter(
             virtual_machine=virtualmachine
             virtual_machine=virtualmachine
         ).prefetch_related(
         ).prefetch_related(
             Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user))
             Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user))
         )
         )
 
 
+        # Secrets
+        secrets = Secret.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
+
         return render(request, 'virtualization/virtualmachine.html', {
         return render(request, 'virtualization/virtualmachine.html', {
             'virtualmachine': virtualmachine,
             'virtualmachine': virtualmachine,
             'interfaces': interfaces,
             'interfaces': interfaces,
             'services': services,
             'services': services,
+            'secrets': secrets,
         })
         })