Changed Secret parent from a GenericForeignKey to ForeignKey(Device)

This commit is contained in:
Jeremy Stretch 2016-03-21 11:42:42 -04:00
parent 3f279dc58b
commit a6108f2fa8
16 changed files with 94 additions and 75 deletions

View File

@ -2,7 +2,7 @@
# Secret
A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a parent object with NetBox, such as a device. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext.
A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a device within NetBox. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext.
Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing user names.

View File

@ -1,6 +1,5 @@
from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MinValueValidator
@ -8,7 +7,6 @@ from django.db import models
from django.db.models import Q, ObjectDoesNotExist
from extras.rpc import RPC_CLIENTS
from secrets.models import Secret
from utilities.fields import NullableCharField
@ -420,7 +418,6 @@ class Device(models.Model):
primary_ip = models.OneToOneField('ipam.IPAddress', related_name='primary_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IP')
ro_snmp = models.CharField(max_length=50, blank=True, verbose_name='SNMP (RO)')
comments = models.TextField(blank=True)
secrets = GenericRelation(Secret)
class Meta:
ordering = ['name']

View File

@ -70,8 +70,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<parent_pk>\d+)/add-secret/$', secret_add, {'parent_model': 'dcim.Device'},
name='device_addsecret'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
# Console ports
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'),

View File

@ -66,6 +66,6 @@ class SecretRoleAdmin(admin.ModelAdmin):
@admin.register(Secret)
class SecretAdmin(admin.ModelAdmin):
list_display = ['parent', 'role', 'name', 'created', 'last_modified']
fields = ['parent', 'role', 'name', 'hash', 'created', 'last_modified']
readonly_fields = ['parent', 'hash', 'created', 'last_modified']
list_display = ['device', 'role', 'name', 'created', 'last_modified']
fields = ['device', 'role', 'name', 'hash', 'created', 'last_modified']
readonly_fields = ['device', 'hash', 'created', 'last_modified']

View File

@ -1,5 +1,6 @@
from rest_framework import serializers
from dcim.api.serializers import DeviceNestedSerializer
from secrets.models import Secret, SecretRole
@ -24,13 +25,13 @@ class SecretRoleNestedSerializer(SecretRoleSerializer):
# Secrets
#
# TODO: Serialize parent info
class SecretSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer()
role = SecretRoleNestedSerializer()
class Meta:
model = Secret
fields = ['id', 'role', 'name', 'hash', 'created', 'last_modified']
fields = ['id', 'device', 'role', 'name', 'hash', 'created', 'last_modified']
class SecretNestedSerializer(SecretSerializer):

View File

@ -2,10 +2,10 @@ from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from django import forms
from django.apps import apps
from django.db.models import Count
from utilities.forms import BootstrapMixin, ConfirmationForm, CSVDataField
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkImportForm, ConfirmationForm, CSVDataField
from .models import Secret, SecretRole, UserKey
@ -53,51 +53,26 @@ class SecretForm(forms.ModelForm, BootstrapMixin):
class SecretFromCSVForm(forms.ModelForm):
parent_name = forms.CharField()
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid secret role.'})
plaintext = forms.CharField()
class Meta:
model = Secret
fields = ['parent_name', 'role', 'name', 'plaintext']
fields = ['device', 'role', 'name', 'plaintext']
def save(self, *args, **kwargs):
s = super(SecretFromCSVForm, self).save(*args, **kwargs)
s.plaintext = str(self.cleaned_data['plaintext'])
return s
class SecretImportForm(forms.Form, BootstrapMixin):
class SecretImportForm(BulkImportForm, BootstrapMixin):
private_key = forms.CharField(widget=forms.HiddenInput())
parent_type = forms.ChoiceField(label='Parent Type', choices=(
('dcim.Device', 'Device'),
))
csv = CSVDataField(csv_form=SecretFromCSVForm)
def clean(self):
parent_type = self.cleaned_data.get('parent_type')
records = self.cleaned_data.get('csv')
if not records or not parent_type:
return
secrets = []
parent_cls = apps.get_model(parent_type)
for i, record in enumerate(records, start=1):
secret_form = SecretFromCSVForm(data=record)
if secret_form.is_valid():
s = secret_form.save(commit=False)
# Set parent
try:
s.parent = parent_cls.objects.get(name=secret_form.cleaned_data['parent_name'])
except parent_cls.DoesNotExist:
self.add_error('csv', "Invalid parent object ({})".format(secret_form.cleaned_data['parent_name']))
# Set plaintext
s.plaintext = str(secret_form.cleaned_data['plaintext'])
secrets.append(s)
else:
for field, errors in secret_form.errors.items():
for e in errors:
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
self.cleaned_data['csv'] = secrets
class SecretBulkEditForm(forms.Form, BootstrapMixin):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-03-21 14:48
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0003_auto_20160304_1642'),
('secrets', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='secret',
name='content_type',
),
migrations.RemoveField(
model_name='secret',
name='object_id',
),
migrations.AddField(
model_name='secret',
name='device',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='secrets', to='dcim.Device'),
preserve_default=False,
),
]

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.1 on 2016-03-21 15:24
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('secrets', '0002_auto_20160321_1448'),
]
operations = [
migrations.AlterModelOptions(
name='secret',
options={'ordering': ['device', 'role', 'name'], 'permissions': (('view_secret', 'Can view secrets'),)},
),
]

View File

@ -5,13 +5,13 @@ from Crypto.PublicKey import RSA
from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.encoding import force_bytes
from dcim.models import Device
def generate_master_key():
"""
@ -176,9 +176,7 @@ class Secret(models.Model):
A secret string of up to 255 bytes in length, stored as both an AES256-encrypted ciphertext and an irreversible
salted SHA256 hash (for plaintext validation).
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
parent = GenericForeignKey('content_type', 'object_id')
device = models.ForeignKey(Device, related_name='secrets')
role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT)
name = models.CharField(max_length=100, blank=True)
ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded
@ -189,7 +187,7 @@ class Secret(models.Model):
plaintext = None
class Meta:
ordering = ['role', 'name']
ordering = ['device', 'role', 'name']
permissions = (
('view_secret', "Can view secrets"),
)
@ -199,8 +197,8 @@ class Secret(models.Model):
super(Secret, self).__init__(*args, **kwargs)
def __unicode__(self):
if self.role and self.parent:
return "{} for {}".format(self.role, self.parent)
if self.role and self.device:
return "{} for {}".format(self.role, self.device)
return "Secret"
def get_absolute_url(self):

View File

@ -9,14 +9,14 @@ from .models import Secret
#
class SecretTable(tables.Table):
parent = tables.LinkColumn('secrets:secret', args=[Accessor('pk')], verbose_name='Parent')
device = tables.LinkColumn('secrets:secret', args=[Accessor('pk')], verbose_name='Device')
role = tables.Column(verbose_name='Role')
name = tables.Column(verbose_name='Name')
last_modified = tables.DateTimeColumn(verbose_name='Last modified')
class Meta:
model = Secret
fields = ('parent', 'role', 'name', 'last_modified')
fields = ('device', 'role', 'name', 'last_modified')
empty_text = "No secrets found."
attrs = {
'class': 'table table-hover',
@ -28,4 +28,4 @@ class SecretBulkEditTable(SecretTable):
class Meta(SecretTable.Meta):
model = None # django_tables2 bugfix
fields = ('pk', 'parent', 'role', 'name')
fields = ('pk', 'device', 'role', 'name')

View File

@ -1,4 +1,3 @@
from django.apps import apps
from django.contrib import messages
from django.contrib.auth.decorators import permission_required, login_required
from django.contrib.auth.mixins import PermissionRequiredMixin
@ -8,6 +7,7 @@ from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
from dcim.models import Device
from utilities.error_handlers import handle_protectederror
from utilities.forms import ConfirmationForm
from utilities.views import BulkEditView, BulkDeleteView, ObjectListView
@ -25,7 +25,7 @@ from .tables import SecretTable, SecretBulkEditTable
@method_decorator(login_required, name='dispatch')
class SecretListView(ObjectListView):
queryset = Secret.objects.select_related('role').prefetch_related('parent')
queryset = Secret.objects.select_related('role').prefetch_related('device')
filter = SecretFilter
filter_form = SecretFilterForm
table = SecretTable
@ -46,13 +46,12 @@ def secret(request, pk):
@permission_required('secrets.add_secret')
@userkey_required()
def secret_add(request, parent_model, parent_pk):
def secret_add(request, pk):
# Retrieve parent object
parent_cls = apps.get_model(parent_model)
parent = get_object_or_404(parent_cls, pk=parent_pk)
# Retrieve device
device = get_object_or_404(Device, pk=pk)
secret = Secret(parent=parent)
secret = Secret(device=device)
uk = UserKey.objects.get(user=request.user)
if request.method == 'POST':
@ -83,7 +82,7 @@ def secret_add(request, parent_model, parent_pk):
return render(request, 'secrets/secret_edit.html', {
'secret': secret,
'form': form,
'cancel_url': parent.get_absolute_url(),
'cancel_url': device.get_absolute_url(),
})

View File

@ -132,7 +132,7 @@
{% csrf_token %}
</form>
<div class="panel-footer text-right">
<a href="{% url 'dcim:device_addsecret' parent_pk=device.pk %}" class="btn btn-xs btn-primary">
<a href="{% url 'dcim:device_addsecret' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add secret
</a>

View File

@ -7,7 +7,7 @@
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'secrets:secret_list' %}">Secrets</a></li>
<li>{{ secret.parent }}</li>
<li><a href="{% url 'dcim:device' pk=secret.device.pk %}">{{ secret.device }}</a></li>
<li>{{ secret.role }}{% if secret.name %} ({{ secret.name }}){% endif %}</li>
</ol>
</div>
@ -35,9 +35,9 @@
</div>
<table class="table table-hover panel-body">
<tr>
<td>Parent</td>
<td>Device</td>
<td>
<a href="{{ secret.parent.get_absolute_url }}">{{ secret.parent }}</a>
<a href="{% url 'dcim:device' pk=secret.device.pk %}">{{ secret.device }}</a>
</td>
</tr>
<tr>

View File

@ -7,7 +7,7 @@
{% for secret in selected_objects %}
<tr>
<td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret }}</a></td>
<td>{{ secret.parent }}</td>
<td>{{ secret.device }}</td>
<td>{{ secret.role }}</td>
<td>{{ secret.name }}</td>
</tr>

View File

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

View File

@ -37,8 +37,8 @@
</thead>
<tbody>
<tr>
<td>Parent</td>
<td>Name of the parent object</td>
<td>Device</td>
<td>Name of the parent device</td>
<td>edge-router1</td>
</tr>
<tr>