Skip to content

Commit

Permalink
key bundles
Browse files Browse the repository at this point in the history
  • Loading branch information
amyasnikov committed Dec 4, 2023
1 parent 75c5357 commit 360cabf
Show file tree
Hide file tree
Showing 13 changed files with 149 additions and 36 deletions.
22 changes: 16 additions & 6 deletions validity/api/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@
from typing import Sequence

from netbox.api.serializers import WritableNestedSerializer
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import JSONField, ModelSerializer

from validity.utils.dbfields import EncryptedDict


def nested_factory(
serializer: type[ModelSerializer], meta_fields: Sequence[str], attributes: Sequence[str] = ("url",)
) -> type[ModelSerializer]:
"""
Creates nested Serializer from regular one
"""

class Meta:
model = serializer.Meta.model
fields = meta_fields
Expand All @@ -17,8 +23,12 @@ class Meta:
bases = tuple(chain(mixins, (WritableNestedSerializer,)))
s_attribs = {a: serializer._declared_fields[a] for a in attributes}
s_attribs["Meta"] = Meta
return type(
name,
bases,
s_attribs,
)
return type(name, bases, s_attribs)


class EncryptedDictField(JSONField):
def to_representation(self, value):
return value.encrypted

def to_internal_value(self, data):
return EncryptedDict(super().to_internal_value(data))
7 changes: 6 additions & 1 deletion validity/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from tenancy.models import Tenant

from validity import models
from .helpers import nested_factory
from .helpers import EncryptedDictField, nested_factory


class ComplianceSelectorSerializer(NetBoxModelSerializer):
Expand Down Expand Up @@ -291,6 +291,8 @@ class Meta(NestedDeviceSerializer.Meta):

class KeyBundleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:validity-api:keybundle-detail")
private_credentials = EncryptedDictField()

class Meta:
model = models.KeyBundle
fields = (
Expand All @@ -306,3 +308,6 @@ class Meta:
"created",
"last_updated",
)


NestedKeyBundleSerializer = nested_factory(KeyBundleSerializer, ("id", "url", "display", "name"))
2 changes: 1 addition & 1 deletion validity/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class ComplianceReportViewSet(NetBoxModelViewSet):


class KeyBundleViewSet(NetBoxModelViewSet):
queryset = models.KeyBundle.objects.prefetch_related('tags')
queryset = models.KeyBundle.objects.prefetch_related("tags")
serializer_class = serializers.KeyBundleSerializer
filterset_class = filtersets.KeyBundleFilterSet

Expand Down
4 changes: 2 additions & 2 deletions validity/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,5 @@ def pk_field(self):


class ConnectionTypeChoices(TextChoices, metaclass=ColoredChoiceMeta):
NETMIKO = 'netmiko', 'blue'
GENERIC = 'generic', 'grey'
netmiko = "netmiko", "blue"
generic = "generic", "grey"
4 changes: 2 additions & 2 deletions validity/forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
ComplianceTestResultFilterForm,
ConfigSerializerFilterForm,
DeviceReportFilterForm,
KeyBundleFilterForm,
NameSetFilterForm,
ReportGroupByForm,
TestResultFilterForm,
KeyBundleFilterForm
)
from .general import ComplianceSelectorForm, ComplianceTestForm, ConfigSerializerForm, NameSetForm, KeyBundleForm
from .general import ComplianceSelectorForm, ComplianceTestForm, ConfigSerializerForm, KeyBundleForm, NameSetForm
6 changes: 4 additions & 2 deletions validity/forms/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from validity.choices import (
BoolOperationChoices,
ConfigExtractionChoices,
ConnectionTypeChoices,
DeviceGroupByChoices,
DynamicPairsChoices,
SeverityChoices,
ConnectionTypeChoices,
)
from .helpers import ExcludeMixin, PlaceholderChoiceField

Expand Down Expand Up @@ -144,4 +144,6 @@ class ComplianceTestFilterForm(NetBoxModelFilterSetForm):
class KeyBundleFilterForm(NetBoxModelFilterSetForm):
model = models.KeyBundle
name = CharField(required=False)
connection_type = PlaceholderChoiceField(required=False, placeholder=_("Connection Type"), choices=ConnectionTypeChoices.choices)
connection_type = PlaceholderChoiceField(
required=False, placeholder=_("Connection Type"), choices=ConnectionTypeChoices.choices
)
23 changes: 12 additions & 11 deletions validity/migrations/0007_keybundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from django.utils.translation import gettext_lazy as _



def create_cf(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
CustomField = apps.get_model("extras", "CustomField")
Expand All @@ -26,11 +25,13 @@ def create_cf(apps, schema_editor):
object_type=ContentType.objects.get_for_model(KeyBundle),
required=False,
)
cf.content_types.set([
ContentType.objects.get_for_model(Device),
ContentType.objects.get_for_model(DeviceType),
ContentType.objects.get_for_model(Manufacturer),
])
cf.content_types.set(
[
ContentType.objects.get_for_model(Device),
ContentType.objects.get_for_model(DeviceType),
ContentType.objects.get_for_model(Manufacturer),
]
)


def delete_cf(apps, schema_editor):
Expand All @@ -56,16 +57,16 @@ class Migration(migrations.Migration):
"custom_field_data",
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
("name", models.CharField(max_length=255)),
("name", models.CharField(max_length=255, unique=True)),
("connection_type", models.CharField(max_length=50)),
("public_credentials", models.JSONField(default=dict)),
("private_credentials", validity.utils.dbfields.EncryptedDictField()),
("public_credentials", models.JSONField(blank=True, default=dict)),
("private_credentials", validity.utils.dbfields.EncryptedDictField(blank=True)),
("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")),
],
options={
"abstract": False,
"ordering": ("name",),
},
bases=(validity.models.base.URLMixin, models.Model),
),
migrations.RunPython(create_cf, delete_cf)
migrations.RunPython(create_cf, delete_cf),
]
2 changes: 1 addition & 1 deletion validity/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from .data import VDataFile, VDataSource
from .device import VDevice
from .keybundle import KeyBundle
from .nameset import NameSet
from .report import ComplianceReport
from .selector import ComplianceSelector
from .serializer import ConfigSerializer
from .test import ComplianceTest
from .test_result import ComplianceTestResult
from .keybundle import KeyBundle
15 changes: 14 additions & 1 deletion validity/models/keybundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@


class KeyBundle(BaseModel):
name = models.CharField(_("Name"), max_length=255)
name = models.CharField(_("Name"), max_length=255, unique=True)
connection_type = models.CharField(_("Connection Type"), max_length=50, choices=ConnectionTypeChoices.choices)
public_credentials = models.JSONField(_("Public Credentials"), default=dict, blank=True)
private_credentials = EncryptedDictField(_("Private Credentials"), blank=True)

class Meta:
ordering = ("name",)

def __str__(self) -> str:
return self.name

@property
def credentials(self):
return self.public_credentials | self.private_credentials.decrypted
Expand All @@ -25,3 +31,10 @@ def bound_devices(self) -> models.QuerySet[Device]:
from .device import VDevice

return VDevice.objects.annotate_keybundle_id().filter(keybundle_id=self.pk)

def serialize_object(self):
private_creds = self.private_credentials
self.private_credentials = self.private_credentials.encrypted
result = super().serialize_object()
self.private_credentials = private_creds
return result
7 changes: 3 additions & 4 deletions validity/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,14 @@ class Meta(NetBoxTable.Meta):
default_columns = fields



class TotalDevicesMixin(NetBoxTable):
total_devices = Column(empty_values=())

count_per: str

def __init__(self, *args, extra_columns=None, **kwargs):
super().__init__(*args, extra_columns=extra_columns, **kwargs)
self.total_devices_map = getattr(models.VDevice.objects, f'count_per_{self.count_per}')()
self.total_devices_map = getattr(models.VDevice.objects, f"count_per_{self.count_per}")()

def render_total_devices(self, record):
return self.total_devices_map.get(record.id, 0)
Expand All @@ -93,7 +92,7 @@ class ConfigSerializerTable(TotalDevicesMixin, NetBoxTable):
name = Column(linkify=True)
extraction_method = ChoiceFieldColumn()

count_per = 'serializer'
count_per = "serializer"

class Meta(NetBoxTable.Meta):
model = models.ConfigSerializer
Expand All @@ -105,7 +104,7 @@ class KeyBundleTable(TotalDevicesMixin, NetBoxTable):
name = Column(linkify=True)
connection_type = ChoiceFieldColumn()

count_per = 'keybundle'
count_per = "keybundle"

class Meta(NetBoxTable.Meta):
model = models.KeyBundle
Expand Down
65 changes: 65 additions & 0 deletions validity/templates/validity/keybundle.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{% extends 'generic/object.html' %}
{% load validity %}
{% load render_table from django_tables2 %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-4">
<div class="row mb-3">
<div class="card">
<h5 class="card-header">Key Bundle</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Connection Type</th>
<td>{{ object | colored_choice:"connection_type" }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="row">
{% include 'inc/panels/tags.html' %}
</div>
</div>
<div class="col col-md-8">
<div class="card">
<div class="card-header">
<div class="row">
<h5 class="col">Credentials</h5>
<div class="col">{% include 'extras/inc/configcontext_format.html' %}</div>
</div>
</div>
<div class="card-body">
<div>
<h6 class="mb-3">Public</h6>
{% include 'extras/inc/configcontext_data.html' with data=object.public_credentials format=format %}
</div>
<div class="mt-4">
<h6 class="mb-3">Private</h6>
{% include 'extras/inc/configcontext_data.html' with data=object.private_credentials.encrypted %}
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Bound Devices</h5>
<div class="card-body">
<div class="pt-0 mb-3 col col-md-3">
{% include 'validity/inc/search_form.html' with model='Device' %}
</div>
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
{%include 'inc/paginator.html' with paginator=table.paginator page=table.page%}
</div>
</div>
</div>
</div>
{% endblock content %}
17 changes: 13 additions & 4 deletions validity/utils/dbfields.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import JSONField, Model


Expand Down Expand Up @@ -83,15 +84,24 @@ def decrypted(self) -> dict:
return {k: v.decrypt() for k, v in self.items()}


class EncryptedFieldEncoder(DjangoJSONEncoder):
def default(self, o: Any) -> Any:
if isinstance(o, EncryptedString):
return o.serialize()
return super().default(o)


class EncryptedDictField(JSONField):
def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault("default", dict)
kwargs["encoder"] = EncryptedFieldEncoder
super().__init__(*args, **kwargs)

def deconstruct(self) -> Any:
name, path, args, kwargs = super().deconstruct()
if kwargs.get("default") == dict:
del kwargs["default"]
del kwargs["encoder"]
return name, path, args, kwargs

def from_db_value(self, value, expression, connection):
Expand All @@ -100,12 +110,11 @@ def from_db_value(self, value, expression, connection):
return EncryptedDict(value)

def get_prep_value(self, value: dict | None) -> dict | None:
value = super().get_prep_value(value)
if isinstance(value, EncryptedDict):
return value.encrypted
value = value.encrypted
if isinstance(value, dict):
return EncryptedDict(value).encrypted
return value
value = EncryptedDict(value).encrypted
return super().get_prep_value(value)

def to_python(self, value):
if value is None or isinstance(value, EncryptedDict):
Expand Down
11 changes: 10 additions & 1 deletion validity/views/keybundle.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from dcim.filtersets import DeviceFilterSet
from dcim.tables import DeviceTable
from netbox.views import generic
from utilities.views import register_model_view

from validity import filtersets, forms, models, tables
from .base import TableMixin


class KeyBundleListView(generic.ObjectListView):
Expand All @@ -12,8 +15,14 @@ class KeyBundleListView(generic.ObjectListView):


@register_model_view(models.KeyBundle)
class KeyBundleView(generic.ObjectView):
class KeyBundleView(TableMixin, generic.ObjectView):
queryset = models.KeyBundle.objects.prefetch_related("tags")
table = DeviceTable
filterset = DeviceFilterSet
object_table_field = "bound_devices"

def get_extra_context(self, request, instance):
return super().get_extra_context(request, instance) | {"format": request.GET.get("format", "yaml")}


@register_model_view(models.KeyBundle, "delete")
Expand Down

0 comments on commit 360cabf

Please sign in to comment.