Просмотр исходного кода

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

#1503: Extend secrets assignment to virtual machines
Jeremy Stretch 5 лет назад
Родитель
Сommit
2b689239ae

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

@@ -6,6 +6,7 @@
 
 ### 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
 * [#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
@@ -30,3 +31,4 @@
 * extras.ExportTemplate: The `template_language` field has been removed
 * 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
+* 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(
         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)
 
     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 dcim.api.nested_serializers import NestedDeviceSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.serializers import TaggedObjectSerializer
+from secrets.constants import SECRET_ASSIGNMENT_MODELS
 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 *
 
 
@@ -23,18 +25,27 @@ class SecretRoleSerializer(ValidatedModelSerializer):
 
 class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     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()
     plaintext = serializers.CharField()
 
     class Meta:
         model = Secret
         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 = []
 
+    @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):
 
         # 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):
-    queryset = Secret.objects.prefetch_related(
-        'device__primary_ip4', 'device__primary_ip6', 'role', 'tags',
-    )
+    queryset = Secret.objects.prefetch_related('role', 'tags')
     serializer_class = serializers.SecretSerializer
     filterset_class = filters.SecretFilterSet
 

+ 8 - 0
netbox/secrets/constants.py

@@ -1,5 +1,13 @@
+from django.db.models import Q
+
+
 #
 # Secrets
 #
 
+SECRET_ASSIGNMENT_MODELS = Q(
+    Q(app_label='dcim', model='device') |
+    Q(app_label='virtualization', model='virtualmachine')
+)
+
 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 extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, TagFilter
+from virtualization.models import VirtualMachine
 from .models import Secret, SecretRole
 
 
@@ -35,16 +36,28 @@ class SecretFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterS
         to_field_name='slug',
         label='Role (slug)',
     )
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
     device = django_filters.ModelMultipleChoiceFilter(
         field_name='device__name',
         queryset=Device.objects.all(),
         to_field_name='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()
 
     class Meta:

+ 35 - 14
netbox/secrets/forms.py

@@ -1,6 +1,7 @@
 from Crypto.Cipher import PKCS1_OAEP
 from Crypto.PublicKey import RSA
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 
 from dcim.models import Device
 from extras.forms import (
@@ -11,6 +12,7 @@ from utilities.forms import (
     BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     SlugField, TagFilterField,
 )
+from virtualization.models import VirtualMachine
 from .constants import *
 from .models import Secret, SecretRole, UserKey
 
@@ -64,8 +66,13 @@ class SecretRoleCSVForm(CSVModelForm):
 class SecretForm(BootstrapMixin, CustomFieldModelForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
+        required=False,
         display_field='display_name'
     )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False
+    )
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
@@ -93,10 +100,21 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
         model = Secret
         fields = [
-            'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
+            'device', 'virtual_machine', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
         ]
 
     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)
 
         # A plaintext value is required when creating a new Secret
@@ -105,28 +123,31 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
 
     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
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
             raise forms.ValidationError({
                 '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):
-    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(
         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.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.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 from django.utils.encoding import force_bytes
 from taggit.managers import TaggableManager
 
-from dcim.models import Device
 from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
 from extras.utils import extras_features
 from utilities.querysets import RestrictedQuerySet
@@ -276,17 +277,21 @@ class SecretRole(ChangeLoggedModel):
 class Secret(ChangeLoggedModel, CustomFieldModel):
     """
     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 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(
         to='secrets.SecretRole',
@@ -310,34 +315,26 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
     objects = RestrictedQuerySet.as_manager()
 
     plaintext = None
-    csv_headers = ['device', 'role', 'name', 'plaintext']
+    csv_headers = ['assigned_object_type', 'assigned_object_id', 'role', 'name', 'plaintext']
 
     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):
         self.plaintext = kwargs.pop('plaintext', None)
         super().__init__(*args, **kwargs)
 
     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):
         return reverse('secrets:secret', args=[self.pk])
 
     def to_csv(self):
         return (
-            self.device,
+            f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}',
+            self.assigned_object_id,
             self.role,
             self.name,
             self.plaintext or '',

+ 6 - 3
netbox/secrets/tables.py

@@ -28,12 +28,15 @@ class SecretRoleTable(BaseTable):
 
 class SecretTable(BaseTable):
     pk = ToggleColumn()
-    device = tables.LinkColumn()
+    assigned_object = tables.Column(
+        linkify=True,
+        verbose_name='Assigned object'
+    )
     tags = TagColumn(
         url_name='secrets:secret_list'
     )
 
     class Meta(BaseTable.Meta):
         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)
 
         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:
             secret.encrypt(self.master_key)
@@ -90,19 +90,22 @@ class SecretTest(APIViewTestCases.APIViewTestCase):
 
         self.create_data = [
             {
-                'device': device.pk,
+                'assigned_object_type': 'dcim.device',
+                'assigned_object_id': device.pk,
                 'role': secret_roles[1].pk,
                 'name': 'Secret 4',
                 'plaintext': 'JKL',
             },
             {
-                'device': device.pk,
+                'assigned_object_type': 'dcim.device',
+                'assigned_object_id': device.pk,
                 'role': secret_roles[1].pk,
                 'name': 'Secret 5',
                 'plaintext': 'MNO',
             },
             {
-                'device': device.pk,
+                'assigned_object_type': 'dcim.device',
+                'assigned_object_id': device.pk,
                 'role': secret_roles[1].pk,
                 'name': 'Secret 6',
                 '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 secrets.filters import *
 from secrets.models import Secret, SecretRole
+from virtualization.models import Cluster, ClusterType, VirtualMachine
 
 
 class SecretRoleTestCase(TestCase):
@@ -51,6 +52,15 @@ class SecretTestCase(TestCase):
         )
         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 = (
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
             SecretRole(name='Secret Role 2', slug='secret-role-2'),
@@ -59,9 +69,12 @@ class SecretTestCase(TestCase):
         SecretRole.objects.bulk_create(roles)
 
         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
         for s in secrets:
@@ -78,9 +91,9 @@ class SecretTestCase(TestCase):
     def test_role(self):
         roles = SecretRole.objects.all()[:2]
         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]}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_device(self):
         devices = Device.objects.all()[:2]
@@ -88,3 +101,10 @@ class SecretTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'device': [devices[0].name, devices[1].name]}
         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)
         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 = {
-            'device': devices[1].pk,
+            'assigned_object_type': 'dcim.device',
+            'assigned_object_id': devices[1].pk,
             'role': secretroles[1].pk,
             'name': 'Secret X',
         }
@@ -100,11 +101,12 @@ class SecretTestCase(
     def test_import_objects(self):
         self.add_permissions('secrets.add_secret')
 
+        device = Device.objects.get(name='Device 1')
         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

+ 3 - 3
netbox/secrets/views.py

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

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

@@ -395,30 +395,16 @@
                                     </table>
                                 </div>
                             {% endif %}
-                            {% if request.user.is_authenticated %}
+                            {% if perms.secrets.view_secret %}
                                 <div class="panel panel-default">
                                     <div class="panel-heading">
                                         <strong>Secrets</strong>
                                     </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 %}
-                                        <form id="secret_form">
-                                            {% csrf_token %}
-                                        </form>
                                         <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">
-                                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                                                Add secret
+                                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add secret
                                             </a>
                                         </div>
                                     {% 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">
                 <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>{{ 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>
         </div>
     </div>
@@ -50,9 +51,9 @@
             </div>
             <table class="table table-hover panel-body">
                 <tr>
-                    <td>Device</td>
+                    <td>Assigned object</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>
                 </tr>
                 <tr>

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

@@ -18,9 +18,24 @@
                 </div>
             {% endif %}
             <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">
-                    {% 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.name %}
                     {% render_field form.userkeys %}

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

@@ -220,6 +220,21 @@
                 </tr>
             </table>
         </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-heading">
                 <strong>Services</strong>
@@ -325,8 +340,10 @@
         {% endif %}
 	</div>
 </div>
+{% include 'secrets/inc/private_key_modal.html' %}
 {% endblock %}
 
 {% block javascript %}
 <script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
+<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
 {% endblock %}

+ 6 - 0
netbox/virtualization/models.py

@@ -270,6 +270,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     comments = models.TextField(
         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)
 
     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 ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from secrets.models import Secret
 from utilities.utils import get_subquery
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
@@ -240,23 +241,30 @@ class VirtualMachineView(ObjectView):
     queryset = VirtualMachine.objects.prefetch_related('tenant__group')
 
     def get(self, request, pk):
-
         virtualmachine = get_object_or_404(self.queryset, pk=pk)
+
+        # Interfaces
         interfaces = VMInterface.objects.restrict(request.user, 'view').filter(
             virtual_machine=virtualmachine
         ).prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user))
         )
+
+        # Services
         services = Service.objects.restrict(request.user, 'view').filter(
             virtual_machine=virtualmachine
         ).prefetch_related(
             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', {
             'virtualmachine': virtualmachine,
             'interfaces': interfaces,
             'services': services,
+            'secrets': secrets,
         })