This commit is contained in:
Andrew Gauger 2018-09-14 21:03:13 +00:00 committed by GitHub
commit fdb986af27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 165 additions and 42 deletions

View File

@ -17,6 +17,7 @@ from dcim.models import (
) )
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
from secrets.models import Secret
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from users.api.serializers import NestedUserSerializer from users.api.serializers import NestedUserSerializer
from utilities.api import ( from utilities.api import (

View File

@ -1287,6 +1287,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
secrets = GenericRelation(
to='secret.Secret'
)
objects = DeviceManager() objects = DeviceManager()
tags = TaggableManager() tags = TaggableManager()

View File

@ -5,6 +5,7 @@ from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.serializers import NestedDeviceSerializer from dcim.api.serializers import NestedDeviceSerializer
from dcim.models import Device
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from secrets.models import Secret, SecretRole from secrets.models import Secret, SecretRole
from utilities.api import ValidatedModelSerializer, WritableNestedSerializer from utilities.api import ValidatedModelSerializer, WritableNestedSerializer
@ -33,8 +34,19 @@ class NestedSecretRoleSerializer(WritableNestedSerializer):
# Secrets # Secrets
# #
class SecretObjectRelatedField(serializers.RelatedField):
"""
GenericRelation object can be extended with different Object types
"""
def to_representation(self, value):
if isinstance(value, Device):
object_serial = NestedDeviceSerializer(value, context=self.context)
dict = object_serial.data.copy()
dict.update({'class': 'device'})
return dict
class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer() object = SecretObjectRelatedField(queryset=Secret.objects.all(), required=False)
role = NestedSecretRoleSerializer() role = NestedSecretRoleSerializer()
plaintext = serializers.CharField() plaintext = serializers.CharField()
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
@ -42,12 +54,11 @@ class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta: class Meta:
model = Secret model = Secret
fields = [ fields = [
'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', 'id', 'object', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
validators = [] validators = []
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
if data.get('plaintext'): if data.get('plaintext'):
s = Secret(plaintext=data['plaintext']) s = Secret(plaintext=data['plaintext'])
@ -57,7 +68,7 @@ class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
# Validate uniqueness of name if one has been provided. # Validate uniqueness of name if one has been provided.
if data.get('name'): if data.get('name'):
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name')) validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('object', 'role', 'name'))
validator.set_context(self) validator.set_context(self)
validator(data) validator(data)

View File

@ -46,7 +46,7 @@ class SecretRoleViewSet(ModelViewSet):
class SecretViewSet(ModelViewSet): class SecretViewSet(ModelViewSet):
queryset = Secret.objects.select_related( queryset = Secret.objects.select_related(
'device__primary_ip4', 'device__primary_ip6', 'role', 'role',
).prefetch_related( ).prefetch_related(
'role__users', 'role__groups', 'role__users', 'role__groups',
) )

View File

@ -34,7 +34,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
) )
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label='Device (ID)', label='Object (ID)',
) )
device = django_filters.ModelMultipleChoiceFilter( device = django_filters.ModelMultipleChoiceFilter(
name='device__name', name='device__name',

View File

@ -0,0 +1,20 @@
# Generated by Django 2.0.8 on 2018-08-10 15:28
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('secrets', '0005_change_logging'),
]
operations = [
migrations.AddField(
model_name='secret',
name='content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AddField(
model_name='secret',
name='object_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 2.0.8 on 2018-08-10 15:29
from django.db import migrations
def migrate_device_secret(apps, schema_editor):
"""
Move data from device foreign key into GenericForeignKey
"""
Secret = apps.get_model('secrets', 'Secret')
if Secret.objects.count() > 0:
ContentType = apps.get_model('contenttypes', 'ContentType')
secret_device_content_type = ContentType.objects.get(app_label='dcim', model='device')
for secret in Secret.objects.all():
secret.content_type = secret_device_content_type
secret.object_id = secret.device.pk
secret.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0058_relax_rack_naming_constraints'),
('secrets', '0006_foreign_key_generic'),
]
operations = [
migrations.RunPython(migrate_device_secret)
]

View File

@ -0,0 +1,32 @@
# Generated by Django 2.0.8 on 2018-08-10 16:37
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('secrets', '0007_populate_generic_foreign_key'),
]
operations = [
migrations.AlterModelOptions(
name='secret',
options={'ordering': ['content_type', 'object_id', 'role', 'name']},
),
migrations.AlterField(
model_name='secret',
name='content_type',
field=models.ForeignKey(default=33, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='secret',
name='object_id',
field=models.PositiveIntegerField(default=1),
),
migrations.AlterUniqueTogether(
name='secret',
unique_together={('content_type', 'object_id', 'role', 'name')},
),
migrations.RemoveField(
model_name='secret',
name='device',
),
]

View File

@ -8,7 +8,9 @@ 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.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
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
@ -322,11 +324,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum A Secret can be up to 65,536 bytes (64KB) 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. 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'
)
role = models.ForeignKey( role = models.ForeignKey(
to='secrets.SecretRole', to='secrets.SecretRole',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -349,39 +346,60 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
default=ContentType.objects.get(app_label='dcim', model='device').pk
)
object_id = models.PositiveIntegerField(default=1)
object = GenericForeignKey('content_type', 'object_id')
tags = TaggableManager() tags = TaggableManager()
plaintext = None plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext'] csv_headers = ['object_id', 'content_type', 'role', 'name', 'plaintext']
class Meta: class Meta:
ordering = ['device', 'role', 'name'] ordering = ['content_type', 'object_id', 'role', 'name']
unique_together = ['device', 'role', 'name'] unique_together = ['content_type', '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(Secret, self).__init__(*args, **kwargs) super(Secret, self).__init__(*args, **kwargs)
def __str__(self): def __str__(self):
if self.role and self.device and self.name: if self.role and self.object and self.name:
return '{} for {} ({})'.format(self.role, self.device, self.name) return '{} for {} ({})'.format(self.role, self.object, self.name)
# Return role and device if no name is set # Return role and device if no name is set
if self.role and self.device: if self.role and self.object:
return '{} for {}'.format(self.role, self.device) return '{} for {}'.format(self.role, self.object)
return 'Secret' return 'Secret'
def get_device_secrets(device):
"""
Returns Secrets given a device
"""
return Secret.objects.filter(
content_type=ContentType.objects.get(
app_label='dcim', model='device'
),
object_id=device.pk
)
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, self.object,
self.role, self.role,
self.name, self.name,
self.plaintext or '', self.plaintext or '',
) )
def object_label(self):
return(str(self.content_type).capitalize())
def _pad(self, s): def _pad(self, s):
""" """
Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B). Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B).

View File

@ -39,8 +39,8 @@ class SecretRoleTable(BaseTable):
class SecretTable(BaseTable): class SecretTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
device = tables.LinkColumn() object = tables.LinkColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Secret model = Secret
fields = ('pk', 'device', 'role', 'name', 'last_updated') fields = ('pk', 'object', 'role', 'name', 'last_updated')

View File

@ -173,17 +173,17 @@ class SecretTest(APITestCase):
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
self.secret1 = Secret( self.secret1 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] object=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1']
) )
self.secret1.encrypt(self.master_key) self.secret1.encrypt(self.master_key)
self.secret1.save() self.secret1.save()
self.secret2 = Secret( self.secret2 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] object=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2']
) )
self.secret2.encrypt(self.master_key) self.secret2.encrypt(self.master_key)
self.secret2.save() self.secret2.save()
self.secret3 = Secret( self.secret3 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] object=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3']
) )
self.secret3.encrypt(self.master_key) self.secret3.encrypt(self.master_key)
self.secret3.save() self.secret3.save()
@ -205,7 +205,8 @@ class SecretTest(APITestCase):
def test_create_secret(self): def test_create_secret(self):
data = { data = {
'device': self.device.pk, 'object_id': self.device.pk,
'content_type': '33',
'role': self.secretrole1.pk, 'role': self.secretrole1.pk,
'name': 'Test Secret 4', 'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext', 'plaintext': 'Secret #4 Plaintext',
@ -226,19 +227,22 @@ class SecretTest(APITestCase):
data = [ data = [
{ {
'device': self.device.pk, 'object_id': self.device.pk,
'content_type': '33',
'role': self.secretrole1.pk, 'role': self.secretrole1.pk,
'name': 'Test Secret 4', 'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext', 'plaintext': 'Secret #4 Plaintext',
}, },
{ {
'device': self.device.pk, 'object_id': self.device.pk,
'content_type': '33',
'role': self.secretrole1.pk, 'role': self.secretrole1.pk,
'name': 'Test Secret 5', 'name': 'Test Secret 5',
'plaintext': 'Secret #5 Plaintext', 'plaintext': 'Secret #5 Plaintext',
}, },
{ {
'device': self.device.pk, 'object_id': self.device.pk,
'content_type': '33',
'role': self.secretrole1.pk, 'role': self.secretrole1.pk,
'name': 'Test Secret 6', 'name': 'Test Secret 6',
'plaintext': 'Secret #6 Plaintext', 'plaintext': 'Secret #6 Plaintext',
@ -257,7 +261,8 @@ class SecretTest(APITestCase):
def test_update_secret(self): def test_update_secret(self):
data = { data = {
'device': self.device.pk, 'object_id': self.device.pk,
'content_type': '33',
'role': self.secretrole2.pk, 'role': self.secretrole2.pk,
'plaintext': 'NewPlaintext', 'plaintext': 'NewPlaintext',
} }

View File

@ -1,10 +1,12 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import base64 import base64
import os
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required, login_required from django.contrib.auth.decorators import permission_required, login_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -71,7 +73,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@method_decorator(login_required, name='dispatch') @method_decorator(login_required, name='dispatch')
class SecretListView(ObjectListView): class SecretListView(ObjectListView):
queryset = Secret.objects.select_related('role', 'device') queryset = Secret.objects.select_related('role').prefetch_related('object')
filter = filters.SecretFilter filter = filters.SecretFilter
filter_form = forms.SecretFilterForm filter_form = forms.SecretFilterForm
table = tables.SecretTable table = tables.SecretTable
@ -93,11 +95,16 @@ class SecretView(View):
@permission_required('secrets.add_secret') @permission_required('secrets.add_secret')
@userkey_required() @userkey_required()
def secret_add(request, pk): def secret_add(request, pk):
path = request.path
path = os.path.normpath(path)
object = path.split(os.sep)[2]
secret = {
'devices': lambda pk: Secret(
object_id=pk,
content_type=ContentType.objects.get(app_label='dcim', model='device')
)
}[object](pk)
# Retrieve device
device = get_object_or_404(Device, pk=pk)
secret = Secret(device=device)
session_key = get_session_key(request) session_key = get_session_key(request)
if request.method == 'POST': if request.method == 'POST':
@ -131,10 +138,14 @@ def secret_add(request, pk):
else: else:
form = forms.SecretForm(instance=secret) form = forms.SecretForm(instance=secret)
return_url = {
'devices': Device.objects.get(pk=pk).get_absolute_url()
}[object]
return render(request, 'secrets/secret_edit.html', { return render(request, 'secrets/secret_edit.html', {
'secret': secret, 'secret': secret,
'form': form, 'form': form,
'return_url': device.get_absolute_url(), 'return_url': return_url,
}) })
@ -247,7 +258,7 @@ class SecretBulkImportView(BulkImportView):
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'secrets.change_secret' permission_required = 'secrets.change_secret'
queryset = Secret.objects.select_related('role', 'device') queryset = Secret.objects.select_related('role').prefetch_related('object')
filter = filters.SecretFilter filter = filters.SecretFilter
table = tables.SecretTable table = tables.SecretTable
form = forms.SecretBulkEditForm form = forms.SecretBulkEditForm
@ -256,7 +267,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secret' permission_required = 'secrets.delete_secret'
queryset = Secret.objects.select_related('role', 'device') queryset = Secret.objects.select_related('role').prefetch_related('object')
filter = filters.SecretFilter filter = filters.SecretFilter
table = tables.SecretTable table = tables.SecretTable
default_return_url = 'secrets:secret_list' default_return_url = 'secrets:secret_list'

View File

@ -9,7 +9,7 @@
<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>{{ secret.object }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
</ol> </ol>
</div> </div>
</div> </div>
@ -48,9 +48,9 @@
</div> </div>
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
<tr> <tr>
<td>Device</td> <td>{{secret.object_label}}</td>
<td> <td>
<a href="{% url 'dcim:device' pk=secret.device.pk %}">{{ secret.device }}</a> <a href="{{ secret.object.get_absolute_url }}">{{ secret.object }}</a>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -21,9 +21,9 @@
<div class="panel-heading"><strong>Secret Attributes</strong></div> <div class="panel-heading"><strong>Secret Attributes</strong></div>
<div class="panel-body"> <div class="panel-body">
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label required">Device</label> <label class="col-md-3 control-label required">{{secret.object_label}}</label>
<div class="col-md-9"> <div class="col-md-9">
<p class="form-control-static">{{ secret.device }}</p> <p class="form-control-static">{{ secret.object }}</p>
</div> </div>
</div> </div>
{% render_field form.role %} {% render_field form.role %}