#1503: Initial work on generic secret assignments (WIP)

This commit is contained in:
Jeremy Stretch 2020-09-18 14:51:09 -04:00
parent 1fd3bef1fc
commit 735b630ddd
7 changed files with 96 additions and 38 deletions

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

@ -35,16 +35,6 @@ 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(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
tag = TagFilter() tag = TagFilter()
class Meta: class Meta:

View File

@ -0,0 +1,49 @@
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')
Secret = apps.get_model('secrets', 'Secret')
device_ct = ContentType.objects.get(app_label='dcim', 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')},
),
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')},
),
migrations.RunPython(
code=device_to_generic_assignment,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='secret',
name='device',
),
]

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,26 @@ 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' blank=True,
null=True
)
assigned_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
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 +320,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

@ -82,6 +82,13 @@ class SecretEditView(ObjectEditView):
model_form = forms.SecretForm model_form = forms.SecretForm
template_name = 'secrets/secret_edit.html' template_name = 'secrets/secret_edit.html'
def alter_obj(self, secret, request, args, kwargs):
if not secret.pk:
# Set assigned_object based on URL kwargs
model = kwargs.get('model')
secret.assigned_object = get_object_or_404(model, pk=kwargs['object_id'])
return secret
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Check that the user has a valid UserKey # Check that the user has a valid UserKey

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>