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

#1503: Extend secrets assignment to virtual machines
This commit is contained in:
Jeremy Stretch 2020-09-22 09:26:57 -04:00 committed by GitHub
commit 2b689239ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 306 additions and 112 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 %}

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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