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 %}
-
{% 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 %}
+
+ {{ secret.role }} |
+ {{ secret.name }} |
+ ******** |
+
+
+
+
+ |
+
+ {% endfor %}
+
+{% 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 @@
- Secrets
- {{ secret.role }}
- - {{ secret.device }}{% if secret.name %} ({{ secret.name }}){% endif %}
+ - {{ secret.assigned_object }}
+ - {{ secret }}
@@ -50,9 +51,9 @@
- Device |
+ Assigned object |
- {{ secret.device }}
+ {{ secret.assigned_object }}
|
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 @@
+ {% 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,
})