diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index b96aa0802..797149643 100644 --- a/docs/release-notes/version-2.10.md +++ b/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`. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 38ed84370..3d7963dbb 100644 --- a/netbox/dcim/models/devices.py +++ b/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() diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 2862259d8..1fd3f19ef 100644 --- a/netbox/secrets/api/serializers.py +++ b/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 diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 7db6f92b6..33cddea2b 100644 --- a/netbox/secrets/api/views.py +++ b/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 diff --git a/netbox/secrets/constants.py b/netbox/secrets/constants.py index a1c3cb3da..16803820e 100644 --- a/netbox/secrets/constants.py +++ b/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 diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 78f25952a..003859b2f 100644 --- a/netbox/secrets/filters.py +++ b/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: diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 8f04edc5b..8c27ff868 100644 --- a/netbox/secrets/forms.py +++ b/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(), diff --git a/netbox/secrets/migrations/0011_secret_generic_assignments.py b/netbox/secrets/migrations/0011_secret_generic_assignments.py new file mode 100644 index 000000000..02a0e0e21 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 23a883103..f5508d47d 100644 --- a/netbox/secrets/models.py +++ b/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 '', diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 5e8c5a8b4..7158b0b13 100644 --- a/netbox/secrets/tables.py +++ b/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') diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 89c18b7d7..91051e77a 100644 --- a/netbox/secrets/tests/test_api.py +++ b/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', diff --git a/netbox/secrets/tests/test_filters.py b/netbox/secrets/tests/test_filters.py index b7ac73f1d..0be1ef594 100644 --- a/netbox/secrets/tests/test_filters.py +++ b/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) diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 3b7519b7b..2dad675a4 100644 --- a/netbox/secrets/tests/test_views.py +++ b/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 diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 2872616b8..b01a19738 100644 --- a/netbox/secrets/views.py +++ b/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 diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ff82a49e2..2918d2fe3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -395,30 +395,16 @@ {% endif %} - {% if request.user.is_authenticated %} + {% if perms.secrets.view_secret %}
Secrets
- {% if secrets %} - - {% for secret in secrets %} - {% include 'secrets/inc/secret_tr.html' %} - {% endfor %} -
- {% else %} -
- None found -
- {% endif %} + {% include 'secrets/inc/assigned_secrets.html' %} {% if perms.secrets.add_secret %} -
- {% csrf_token %} -
{% endif %} diff --git a/netbox/templates/secrets/inc/assigned_secrets.html b/netbox/templates/secrets/inc/assigned_secrets.html new file mode 100644 index 000000000..2da526a5c --- /dev/null +++ b/netbox/templates/secrets/inc/assigned_secrets.html @@ -0,0 +1,26 @@ +{% if secrets %} + + {% for secret in secrets %} + + + + + + + {% endfor %} +
{{ secret.role }}{{ secret.name }}******** + + + +
+{% else %} +
+ None found +
+{% endif %} diff --git a/netbox/templates/secrets/inc/secret_tr.html b/netbox/templates/secrets/inc/secret_tr.html deleted file mode 100644 index 2af609289..000000000 --- a/netbox/templates/secrets/inc/secret_tr.html +++ /dev/null @@ -1,16 +0,0 @@ - - {{ secret.role }} - {{ secret.name }} - ******** - - - - - - diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 841d9843a..ce7c37c40 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -11,7 +11,8 @@
@@ -50,9 +51,9 @@ - + diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index 0cb1eefef..d3c2f88dc 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -18,9 +18,24 @@ {% endif %}
-
Secret Attributes
+
+ Secret Assignment +
- {% render_field form.device %} + {% with vm_tab_active=form.initial.virtual_machine %} + +
+
+ {% render_field form.device %} +
+
+ {% render_field form.virtual_machine %} +
+
+ {% endwith %} {% render_field form.role %} {% render_field form.name %} {% render_field form.userkeys %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ea33aa460..7eabcf504 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -220,6 +220,21 @@
DeviceAssigned object - {{ secret.device }} + {{ secret.assigned_object }}
+ {% if perms.secrets.view_secret %} +
+
+ Secrets +
+ {% include 'secrets/inc/assigned_secrets.html' %} + {% if perms.secrets.add_secret %} + + {% endif %} +
+ {% endif %}
Services @@ -325,8 +340,10 @@ {% endif %}
+{% include 'secrets/inc/private_key_modal.html' %} {% endblock %} {% block javascript %} + {% endblock %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index a492370ef..e81ee1e49 100644 --- a/netbox/virtualization/models.py +++ b/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() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a06a2e5ff..5e4b99553 100644 --- a/netbox/virtualization/views.py +++ b/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, })