diff --git a/.github/labels.toml b/.github/labels.toml deleted file mode 100644 index b063e45..0000000 --- a/.github/labels.toml +++ /dev/null @@ -1,119 +0,0 @@ -["type: performance"] -color = "6572A4" -name = "type: performance" -description = "" - -["status: abandoned"] -color = "fc9580" -name = "status: abandoned" -description = "It's believed that this issue is no longer important to the requestor and no one else has shown an i" - -["status: accepted"] -color = "d66039" -name = "status: accepted" -description = "It's clear what the subject of the issue is about, and what the resolution should be." - -["status: available"] -color = "fcefa6" -name = "status: available" -description = "No one has claimed responsibility for resolving this issue." - -["status: blocked"] -color = "ffcc9e" -name = "status: blocked" -description = "There is another issue that needs to be resolved first, or a specific person is required to comment" - -["status: completed"] -color = "250077" -name = "status: completed" -description = "Nothing further to be done with this issue." - -["status: in progress"] -color = "f4c9a1" -name = "status: in progress" -description = "This issue is being worked on" - -["status: on hold"] -color = "c8a2f9" -name = "status: on hold" -description = "Similar to blocked, but is assigned to someone" - -["status: review needed"] -color = "e99695" -name = "status: review needed" -description = "The issue has a PR attached to it which needs to be reviewed." - -["type: breaking"] -color = "d6a30c" -name = "type: breaking" -description = "" - -["type: bug"] -color = "d73a4a" -name = "type: bug" -description = "Something isn't working" - -["type: dependencies"] -color = "0366d6" -name = "type: dependencies" -description = "Pull requests that update a dependency file" - -["type: deprecated"] -color = "2e0baa" -name = "type: deprecated" -description = "" - -["type: docs"] -color = "0075ca" -name = "type: docs" -description = "Improvements or additions to documentation" - -["type: duplicate"] -color = "cfd3d7" -name = "type: duplicate" -description = "This issue or pull request already exists" - -["type: feature"] -color = "a2eeef" -name = "type: feature" -description = "New feature or request" - -["type: help wanted"] -color = "008672" -name = "type: help wanted" -description = "Extra attention is needed" - -["type: invalid"] -color = "e4e669" -name = "type: invalid" -description = "This doesn't seem right" - -["type: maintenance"] -color = "c2e0c6" -name = "type: maintenance" -description = "" - -["type: question"] -color = "d876e3" -name = "type: question" -description = "Further information is requested" - -["type: security"] -color = "7dede7" -name = "type: security" -description = "Pull requests that address a security vulnerability" - -["type: tests"] -color = "F25F44" -name = "type: tests" -description = "" - -["type: wontfix"] -color = "ffffff" -name = "type: wontfix" -description = "This will not be worked on" - -["type: ui/ux"] -color = "5319E7" -name = "type: ui/ux" -description = "UI/UX design" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5afbed..269bd19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.12 - name: Lint Code Base uses: github/super-linter/slim@v4 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd205b..55370f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,43 @@ ## [Unreleased](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/HEAD) -[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.0...HEAD) +[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.2...HEAD) + +**Closed issues:** + +- Netbox 4.0 Support [\#136](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/136) +- \[Docs\]: Add help for upgrading from netbox 2.11 to 3.x [\#134](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/134) +- \[Bug\]: List View action Buttons not Visible [\#132](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/132) +- \[Feature\]: Hashicorp Vault integration? [\#125](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/125) +- \[Bug\]: User Key Add Error: `'str' object has no attribute '_meta'` [\#116](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/116) +- \[Bug\]: Cannot create more than one un-named secret for an object [\#100](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/100) +- \[Feature\]: pass session key in GET/POST requests data [\#85](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/85) +- \[Feature\]: Add button to export keys to a file for download and a button to copy the private key [\#16](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/16) + +**Merged pull requests:** + +- Bump braces from 3.0.2 to 3.0.3 in /netbox\_secrets/project-static in the npm\_and\_yarn group across 1 directory [\#140](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/140) ([dependabot[bot]](https://github.com/apps/dependabot)) + +## [v1.10.2](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.10.2) (2024-01-26) + +[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.9.2...v1.10.2) + +**Merged pull requests:** + +- Reverted 8257a03 to fix filtersets [\#129](https://github.com/Onemind-Services-LLC/netbox-secrets/pull/129) ([abhi1693](https://github.com/abhi1693)) + +## [v1.9.2](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.9.2) (2024-01-26) + +[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.1...v1.9.2) + +**Closed issues:** + +- \[Feature\]: Disable encryption? [\#126](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/126) +- Mismatch in PluginConfig Attributes and Update descriptions in setup.py file [\#124](https://github.com/Onemind-Services-LLC/netbox-secrets/issues/124) + +## [v1.10.1](https://github.com/Onemind-Services-LLC/netbox-secrets/tree/v1.10.1) (2024-01-03) + +[Full Changelog](https://github.com/Onemind-Services-LLC/netbox-secrets/compare/v1.10.0...v1.10.1) **Closed issues:** diff --git a/Dockerfile b/Dockerfile index 8569e60..06feaa9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG NETBOX_VARIANT=v3.7 +ARG NETBOX_VARIANT=v4.0 FROM netboxcommunity/netbox:${NETBOX_VARIANT} diff --git a/README.md b/README.md index dde23a5..5641460 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ and stability. | 3.5.x | 1.8.x | | 3.6.x | 1.9.x | | 3.7.x | 1.10.x | +| 4.0.x | 2.0.x | # Installation diff --git a/docker-compose.yml b/docker-compose.yml index 4a1024e..c5dd00e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.4' - services: netbox: build: @@ -14,7 +12,7 @@ services: # postgres postgres: - image: postgres:14-alpine + image: postgres:16-alpine env_file: env/postgres.env # redis diff --git a/netbox_secrets/__init__.py b/netbox_secrets/__init__.py index 3e02853..b59c600 100644 --- a/netbox_secrets/__init__.py +++ b/netbox_secrets/__init__.py @@ -1,7 +1,8 @@ from importlib.metadata import metadata from django.db.utils import OperationalError, ProgrammingError -from extras.plugins import PluginConfig + +from netbox.plugins import PluginConfig metadata = metadata('netbox_secrets') @@ -14,8 +15,8 @@ class NetBoxSecrets(PluginConfig): author = metadata.get('Author') author_email = metadata.get('Author-email') base_url = 'secrets' - min_version = '3.7.0' - max_version = '3.7.99' + min_version = '4.0.0' + max_version = '4.0.99' required_settings = [] default_settings = { 'apps': ['dcim.device', 'virtualization.virtualmachine'], diff --git a/netbox_secrets/admin.py b/netbox_secrets/admin.py deleted file mode 100644 index 406b022..0000000 --- a/netbox_secrets/admin.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.contrib import admin, messages -from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME -from django.shortcuts import redirect, render - -from .forms import ActivateUserKeyForm -from .models import UserKey - - -@admin.register(UserKey) -class UserKeyAdmin(admin.ModelAdmin): - actions = ['activate_selected'] - list_display = ['user', 'is_filled', 'is_active', 'created'] - fields = ['user', 'public_key', 'is_active', 'last_updated'] - readonly_fields = ['user', 'is_active', 'last_updated'] - - def has_add_permission(self, request): - # Don't allow a user to create a new public key directly. - return False - - def has_delete_permission(self, request, obj=None): - # Don't allow a user to delete a public key directly. - return False - - def get_readonly_fields(self, request, obj=None): - # Don't allow a user to modify an existing public key directly. - if obj and obj.public_key: - return ['public_key'] + self.readonly_fields - return self.readonly_fields - - @admin.action(description='Activate selected public keys', permissions=['change']) - def activate_selected(self, request, queryset): - """ - Enable bulk activation of UserKeys - """ - try: - my_userkey = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - messages.error(request, "You do not have a User Key.") - return redirect('admin:netbox_secrets_userkey_changelist') - - if not my_userkey.is_active(): - messages.error(request, "Your User Key is not active.") - return redirect('admin:netbox_secrets_userkey_changelist') - - if 'activate' in request.POST: - form = ActivateUserKeyForm(request.POST) - if form.is_valid(): - master_key = my_userkey.get_master_key(form.cleaned_data['secret_key']) - if master_key is not None: - for uk in form.cleaned_data[ACTION_CHECKBOX_NAME]: - uk.activate(master_key) - messages.success( - request, - "Successfully activated {} user keys.".format(len(form.cleaned_data[ACTION_CHECKBOX_NAME])), - ) - return redirect('admin:netbox_secrets_userkey_changelist') - else: - messages.error( - request, - "Invalid private key provided. Unable to retrieve master key.", - extra_tags='error', - ) - else: - form = ActivateUserKeyForm(initial={ACTION_CHECKBOX_NAME: request.POST.getlist(ACTION_CHECKBOX_NAME)}) - - return render( - request, - 'netbox_secrets/activate_keys.html', - { - 'form': form, - }, - ) diff --git a/netbox_secrets/api/nested_serializers.py b/netbox_secrets/api/nested_serializers.py deleted file mode 100644 index 47f8759..0000000 --- a/netbox_secrets/api/nested_serializers.py +++ /dev/null @@ -1,44 +0,0 @@ -from netbox.api.serializers import WritableNestedSerializer -from rest_framework import serializers - -from ..models import * - -__all__ = [ - 'NestedSecretRoleSerializer', - 'NestedSecretSerializer', - 'NestedSessionKeySerializer', - 'NestedUserKeySerializer', -] - - -class NestedSecretSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secret-detail') - - class Meta: - model = Secret - fields = ['id', 'url', 'display', 'name'] - - -class NestedSecretRoleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secretrole-detail') - secret_count = serializers.IntegerField(read_only=True) - - class Meta: - model = SecretRole - fields = ['id', 'url', 'display', 'name', 'slug', 'secret_count'] - - -class NestedSessionKeySerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:sessionkey-detail') - - class Meta: - model = SessionKey - fields = ['id', 'url', 'display'] - - -class NestedUserKeySerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:userkey-detail') - - class Meta: - model = UserKey - fields = ['id', 'url', 'display'] diff --git a/netbox_secrets/api/serializers.py b/netbox_secrets/api/serializers.py index 3d7b9c8..d8a42b8 100644 --- a/netbox_secrets/api/serializers.py +++ b/netbox_secrets/api/serializers.py @@ -1,14 +1,18 @@ +from functools import cached_property + from django.contrib.contenttypes.models import ContentType +from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field -from netbox.api.fields import ContentTypeField -from netbox.api.serializers import NetBoxModelSerializer -from netbox.constants import NESTED_SERIALIZER_PREFIX from rest_framework import serializers -from utilities.api import get_serializer_for_model +from rest_framework.utils.serializer_helpers import BindingDict +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import NetBoxModelSerializer +from users.api.serializers import UserSerializer +from utilities.api import get_related_object_by_attrs, get_serializer_for_model from ..constants import SECRET_ASSIGNABLE_MODELS from ..models import * -from .nested_serializers import * + __all__ = [ 'SecretRoleSerializer', @@ -25,8 +29,11 @@ # -class UserKeySerializer(serializers.ModelSerializer): +class UserKeySerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:userkey-detail') + user = UserSerializer( + nested=True, + ) public_key = serializers.CharField() private_key = serializers.CharField( write_only=True, @@ -45,6 +52,7 @@ class Meta: 'id', 'url', 'display', + 'user', 'public_key', 'private_key', 'created', @@ -52,10 +60,7 @@ class Meta: 'is_active', 'is_filled', ] - - @extend_schema_field(serializers.CharField()) - def get_display(self, obj): - return str(obj) + brief_fields = ('id', 'display', 'url') # @@ -68,7 +73,7 @@ class SessionKeySerializer(serializers.ModelSerializer): display = serializers.SerializerMethodField(read_only=True) - userkey = NestedUserKeySerializer() + userkey = UserKeySerializer(nested=True) session_key = serializers.SerializerMethodField( read_only=True, @@ -85,15 +90,49 @@ class Meta: 'session_key', 'created', ] + brief_fields = ('id', 'display', 'url') - @extend_schema_field(serializers.CharField()) + @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): return str(obj) - @extend_schema_field(serializers.CharField()) + @extend_schema_field(OpenApiTypes.STR) def get_session_key(self, obj): return self.context.get('session_key', None) + def __init__(self, *args, nested=False, fields=None, **kwargs): + self.nested = nested + self._requested_fields = fields + if self.nested: + self.validators = [] + if self.nested and not fields: + self._requested_fields = getattr(self.Meta, 'brief_fields', None) + super().__init__(*args, **kwargs) + + def to_internal_value(self, data): + # If initialized as a nested serializer, we should expect to receive the attrs or PK + # identifying a related object. + if self.nested: + queryset = self.Meta.model.objects.all() + return get_related_object_by_attrs(queryset, data) + + return super().to_internal_value(data) + + @cached_property + def fields(self): + """ + Override the fields property to check for requested fields. If defined, + return only the applicable fields. + """ + if not self._requested_fields: + return super().fields + + fields = BindingDict(self) + for key, value in self.get_fields().items(): + if key in self._requested_fields: + fields[key] = value + return fields + class SessionKeyCreateSerializer(serializers.ModelSerializer): private_key = serializers.CharField( @@ -108,8 +147,8 @@ class SessionKeyCreateSerializer(serializers.ModelSerializer): class Meta: model = SessionKey fields = [ - 'private_key', 'preserve_key', + 'private_key', ] @@ -137,6 +176,7 @@ class Meta: 'last_updated', 'secret_count', ] + brief_fields = ('id', 'name', 'display', 'url', 'secret_count', 'slug') # @@ -148,7 +188,7 @@ class SecretSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets-api:secret-detail') assigned_object_type = ContentTypeField(queryset=ContentType.objects.filter(SECRET_ASSIGNABLE_MODELS)) assigned_object = serializers.SerializerMethodField(read_only=True) - role = NestedSecretRoleSerializer() + role = SecretRoleSerializer(nested=True) plaintext = serializers.CharField() class Meta: @@ -172,12 +212,15 @@ class Meta: 'last_updated', ] validators = [] + brief_fields = ('id', 'display', 'name', 'url') - @extend_schema_field(serializers.DictField()) + @extend_schema_field(serializers.JSONField()) def get_assigned_object(self, obj): - serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) + if obj.assigned_object is None: + return None + serializer = get_serializer_for_model(obj.assigned_object) context = {'request': self.context['request']} - return serializer(obj.assigned_object, context=context).data + return serializer(obj.assigned_object, nested=True, context=context).data def validate(self, data): # Encrypt plaintext data using the master key provided from the view context @@ -195,3 +238,8 @@ def validate(self, data): class RSAKeyPairSerializer(serializers.Serializer): public_key = serializers.CharField() private_key = serializers.CharField() + + +class ActivateUserKeySerializer(serializers.Serializer): + private_key = serializers.CharField() + user_keys = serializers.ListField() diff --git a/netbox_secrets/api/urls.py b/netbox_secrets/api/urls.py index 6e54106..ecf0e63 100644 --- a/netbox_secrets/api/urls.py +++ b/netbox_secrets/api/urls.py @@ -13,5 +13,6 @@ # Miscellaneous router.register('get-session-key', views.GetSessionKeyViewSet, basename='get-session-key') router.register('generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair') +router.register('activate-user-key', views.ActivateUserKeyViewSet, basename='activate-user-keys') urlpatterns = router.urls diff --git a/netbox_secrets/api/views.py b/netbox_secrets/api/views.py index ef8f51b..600e169 100644 --- a/netbox_secrets/api/views.py +++ b/netbox_secrets/api/views.py @@ -4,16 +4,16 @@ from django.conf import settings from django.http import HttpResponseBadRequest from drf_spectacular import utils as drf_utils -from rest_framework import mixins as drf_mixins +from rest_framework import mixins as drf_mixins, status from rest_framework.exceptions import ValidationError from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.routers import APIRootView -from rest_framework.viewsets import ModelViewSet, ViewSet +from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet -from netbox.api.viewsets import BaseViewSet, mixins, NetBoxModelViewSet -from utilities.utils import count_related +from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet, mixins +from utilities.query import count_related from . import serializers from .. import constants, exceptions, filtersets, models @@ -38,12 +38,10 @@ def get_view_name(self): # # User Key # -class UserKeyViewSet(ModelViewSet): +class UserKeyViewSet(ReadOnlyModelViewSet): queryset = models.UserKey.objects.all() serializer_class = serializers.UserKeySerializer - - def get_queryset(self): - return super().get_queryset().filter(user=self.request.user) + filterset_class = filtersets.UserKeyFilterSet # @@ -141,7 +139,6 @@ class SessionKeyViewSet( drf_mixins.ListModelMixin, drf_mixins.RetrieveModelMixin, drf_mixins.DestroyModelMixin, - mixins.BriefModeMixin, mixins.BulkDestroyModelMixin, mixins.ObjectValidationMixin, BaseViewSet, @@ -346,3 +343,48 @@ def create(self, request): response.set_cookie('session_key', value=encoded_key) return response + + +class ActivateUserKeyViewSet(ViewSet): + """ + This endpoint expects a private key and a list of user keys to be activated. + The private key is used to derive a master key, which is then used to activate + each user key provided. + """ + + permission_classes = [IsAuthenticated] + serializer_class = serializers.ActivateUserKeySerializer + parser_classes = [JSONParser, FormParser, MultiPartParser] + + def create(self, request): + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + private_key = serializer.validated_data['private_key'] + user_keys = serializer.validated_data['user_keys'] + + # Validate user key + try: + user_key = models.UserKey.objects.get(user=request.user) + except models.UserKey.DoesNotExist: + return HttpResponseBadRequest(ERR_USERKEY_MISSING) + + if not user_key.is_active(): + return HttpResponseBadRequest(ERR_USERKEY_INACTIVE) + + # Validate private key + master_key = user_key.get_master_key(private_key) + if master_key is None: + return HttpResponseBadRequest(ERR_PRIVKEY_INVALID) + + activated_keys = 0 + for key_data in user_keys: + try: + user_key = models.UserKey.objects.get(pk=key_data) + user_key.activate(master_key) + activated_keys += 1 + except models.UserKey.DoesNotExist: + return HttpResponseBadRequest(f"User key with id {key_data} does not exist.") + + return Response(f"Successfully activated {activated_keys} user keys.", status=status.HTTP_200_OK) diff --git a/netbox_secrets/constants.py b/netbox_secrets/constants.py index 71f5e17..c57a3f4 100644 --- a/netbox_secrets/constants.py +++ b/netbox_secrets/constants.py @@ -14,6 +14,10 @@ SECRET_PLAINTEXT_MAX_LENGTH = 65535 +# General-purpose tokens +CENSOR_MASTER_KEY = '********' +CENSOR_MASTER_KEY_CHANGED = '***CHANGED***' + # # Session Keys diff --git a/netbox_secrets/filtersets.py b/netbox_secrets/filtersets.py index ac57c41..88dfd31 100644 --- a/netbox_secrets/filtersets.py +++ b/netbox_secrets/filtersets.py @@ -1,5 +1,6 @@ import django_filters from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ @@ -8,7 +9,7 @@ from tenancy.models import Contact from utilities.filters import ContentTypeFilter, MultiValueCharFilter from .constants import SECRET_ASSIGNABLE_MODELS -from .models import Secret, SecretRole +from .models import Secret, SecretRole, UserKey __all__ = [ 'SecretFilterSet', @@ -18,6 +19,30 @@ plugin_settings = settings.PLUGINS_CONFIG['netbox_secrets'] +class UserKeyFilterSet(NetBoxModelFilterSet): + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=get_user_model().objects.all(), + label=_('User (ID)'), + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=get_user_model().objects.all(), + to_field_name='username', + label=_('User (name)'), + ) + + class Meta: + model = UserKey + fields = [ + 'id', + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(Q(user__username__icontains=value)) + + class SecretRoleFilterSet(NetBoxModelFilterSet): name = MultiValueCharFilter(lookup_expr='iexact') diff --git a/netbox_secrets/forms/bulk_edit.py b/netbox_secrets/forms/bulk_edit.py index 14d00ff..08bc311 100644 --- a/netbox_secrets/forms/bulk_edit.py +++ b/netbox_secrets/forms/bulk_edit.py @@ -1,7 +1,8 @@ from django import forms + from netbox.forms import NetBoxModelBulkEditForm from utilities.forms.fields import CommentField - +from utilities.forms.rendering import FieldSet from ..models import Secret, SecretRole __all__ = [ @@ -17,7 +18,7 @@ class SecretRoleBulkEditForm(NetBoxModelBulkEditForm): model = SecretRole - fieldsets = ((None, ('description',)),) + FieldSets = (FieldSet('description', name=None),) class Meta: nullable_fields = ['description', 'comments'] @@ -30,7 +31,7 @@ class SecretBulkEditForm(NetBoxModelBulkEditForm): model = Secret - fieldsets = ((None, ('description',)),) + FieldSets = (FieldSet('description', name=None),) class Meta: nullable_fields = ['description', 'comments'] diff --git a/netbox_secrets/forms/filterset.py b/netbox_secrets/forms/filterset.py index 3050058..778f2f9 100644 --- a/netbox_secrets/forms/filterset.py +++ b/netbox_secrets/forms/filterset.py @@ -7,6 +7,7 @@ DynamicModelMultipleChoiceField, TagFilterField, ) +from utilities.forms.rendering import FieldSet from ..constants import * from ..models import Secret, SecretRole @@ -18,7 +19,10 @@ class SecretRoleFilterForm(NetBoxModelFilterSetForm): model = SecretRole - fieldsets = ((None, ('q', 'filter_id', 'tag')), ('Secret Role', ('id',))) + fieldsets = ( + FieldSet('q', 'filter_id', 'tag', name=None), + FieldSet('id', name=_('Secret Role')), + ) id = DynamicModelMultipleChoiceField(queryset=SecretRole.objects.all(), required=False, label=_('Roles Name')) tag = TagFilterField(model) @@ -27,9 +31,9 @@ class SecretFilterForm(NetBoxModelFilterSetForm): model = Secret fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - ('Secret', ('id',)), - ('Attributes', ('role_id', 'assigned_object_type_id')), + FieldSet('q', 'filter_id', 'tag', name=None), + FieldSet('id', name=_('Secret')), + FieldSet('role_id', 'assigned_object_type_id', name=_("Attributes")), ) id = DynamicModelMultipleChoiceField(queryset=Secret.objects.all(), required=False, label=_('Name')) diff --git a/netbox_secrets/forms/model_forms.py b/netbox_secrets/forms/model_forms.py index 2b50de9..d98439b 100644 --- a/netbox_secrets/forms/model_forms.py +++ b/netbox_secrets/forms/model_forms.py @@ -1,9 +1,11 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms +from django.utils.translation import gettext as _ + from netbox.forms import NetBoxModelForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField - +from utilities.forms.rendering import FieldSet from ..constants import * from ..models import Secret, SecretRole, UserKey @@ -42,7 +44,7 @@ def validate_rsa_key(key, is_secret=True): class SecretRoleForm(NetBoxModelForm): slug = SlugField() - fieldsets = ((None, ('name', 'slug', 'description', 'tags')),) + fieldsets = (FieldSet('name', 'slug', 'description', 'tags', name=None),) class Meta: model = SecretRole @@ -76,8 +78,8 @@ class SecretForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (None, ('name', 'description', 'role', 'tags')), - ('Secret Data', ('plaintext', 'plaintext2')), + FieldSet('name', 'description', 'role', 'tags', name=None), + FieldSet('plaintext', 'plaintext2', name=_('Secret Data')), ) class Meta: @@ -135,12 +137,10 @@ def clean_public_key(self): class ActivateUserKeyForm(forms.Form): - _selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys') + user_keys = forms.ModelMultipleChoiceField( + queryset=UserKey.objects.filter(master_key_cipher__isnull=True), label='User Keys' + ) secret_key = forms.CharField( - widget=forms.Textarea( - attrs={ - 'class': 'vLargeTextField', - }, - ), - label='Your private key', + widget=forms.Textarea(attrs={'class': 'vLargeTextField'}), + label='Your Private Key', ) diff --git a/netbox_secrets/graphql/__init__.py b/netbox_secrets/graphql/__init__.py index 747bb4f..8edbe0e 100644 --- a/netbox_secrets/graphql/__init__.py +++ b/netbox_secrets/graphql/__init__.py @@ -1 +1,3 @@ -from .types import * # noqa +from .schema import NetboxSecretsQuery + +schema = [NetboxSecretsQuery] diff --git a/netbox_secrets/graphql/filters.py b/netbox_secrets/graphql/filters.py new file mode 100644 index 0000000..117c54d --- /dev/null +++ b/netbox_secrets/graphql/filters.py @@ -0,0 +1,22 @@ +import strawberry_django +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +from ..models import * +from ..filtersets import * + +__all__ = [ + 'SecretFilter', + 'SecretRoleFilter', +] + + +@strawberry_django.filter(Secret, lookups=True) +@autotype_decorator(SecretFilterSet) +class SecretFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(SecretRole, lookups=True) +@autotype_decorator(SecretRoleFilterSet) +class SecretRoleFilter(BaseFilterMixin): + pass diff --git a/netbox_secrets/graphql/schema.py b/netbox_secrets/graphql/schema.py index 1a00ca3..952f53e 100644 --- a/netbox_secrets/graphql/schema.py +++ b/netbox_secrets/graphql/schema.py @@ -1,12 +1,22 @@ -import graphene -from netbox.graphql.fields import ObjectField, ObjectListField +from typing import List +import strawberry +import strawberry_django + +from ..models import * from .types import * -class SecretsQuery(graphene.ObjectType): - secret = ObjectField(SecretType) - secret_list = ObjectListField(SecretType) +@strawberry.type +class NetboxSecretsQuery: + @strawberry.field + def secret_roles(self, id: int) -> List[SecretRoleType]: + return SecretRole.objects.get(pk=id) + + secret_roles_list: List[SecretRoleType] = strawberry_django.field() + + @strawberry.field + def secrets(self, id: int) -> List[SecretType]: + return Secret.objects.get(pk=id) - secretrole = ObjectField(SecretRoleType) - secretrole_list = ObjectListField(SecretRoleType) + secrets_list: List[SecretType] = strawberry_django.field() diff --git a/netbox_secrets/graphql/types.py b/netbox_secrets/graphql/types.py index 3ae4006..4311e49 100644 --- a/netbox_secrets/graphql/types.py +++ b/netbox_secrets/graphql/types.py @@ -1,6 +1,11 @@ -from netbox.graphql.types import NetBoxObjectType, ObjectType +from typing import Annotated -from netbox_secrets import filtersets, models +import strawberry +import strawberry_django + +from netbox.graphql.types import NetBoxObjectType +from .filters import * +from ..models import * __all__ = [ 'SecretRoleType', @@ -8,26 +13,15 @@ ] -class SecretRoleType(ObjectType): - class Meta: - model = models.SecretRole - fields = '__all__' - filterset_class = filtersets.SecretRoleFilterSet +@strawberry_django.type(SecretRole, fields="__all__", filters=SecretRoleFilter) +class SecretRoleType(NetBoxObjectType): + name: str + slug: str + description: str +@strawberry_django.type(Secret, exclude=('ciphertext', 'hash', 'plaintext'), filters=SecretFilter) class SecretType(NetBoxObjectType): - class Meta: - model = models.Secret - fields = [ - 'id', - 'name', - 'description', - 'role', - 'assigned_object_type', - 'assigned_object_id', - 'comments', - 'tags', - 'created', - 'last_updated', - ] - filterset_class = filtersets.SecretFilterSet + role: Annotated['SecretRoleType', strawberry.lazy('netbox_secrets.graphql.types')] + name: str + description: str diff --git a/netbox_secrets/migrations/0008_userkey_custom_field_data_userkey_tags.py b/netbox_secrets/migrations/0008_userkey_custom_field_data_userkey_tags.py new file mode 100644 index 0000000..870edb8 --- /dev/null +++ b/netbox_secrets/migrations/0008_userkey_custom_field_data_userkey_tags.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.6 on 2024-07-02 10:23 + +import taggit.managers +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0115_convert_dashboard_widgets'), + ('netbox_secrets', '0007_secret__object_repr_secret_comments_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='userkey', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + migrations.AddField( + model_name='userkey', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox_secrets/models/secrets.py b/netbox_secrets/models/secrets.py index 9e390fc..75b4594 100644 --- a/netbox_secrets/models/secrets.py +++ b/netbox_secrets/models/secrets.py @@ -5,17 +5,16 @@ from Crypto.Util import strxor from django.conf import settings from django.contrib.auth.hashers import check_password, make_password -from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes -from netbox.models import PrimaryModel -from netbox.models.features import ChangeLoggingMixin, EventRulesMixin -from utilities.querysets import RestrictedQuerySet +from netbox.models import NetBoxModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet +from ..constants import CENSOR_MASTER_KEY, CENSOR_MASTER_KEY_CHANGED from ..exceptions import InvalidKey from ..hashers import SecretValidationHasher from ..querysets import UserKeyQuerySet @@ -31,7 +30,7 @@ plugin_settings = settings.PLUGINS_CONFIG.get('netbox_secrets', {}) -class UserKey(ChangeLoggingMixin, EventRulesMixin): +class UserKey(NetBoxModel): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's @@ -39,7 +38,9 @@ class UserKey(ChangeLoggingMixin, EventRulesMixin): """ id = models.BigAutoField(primary_key=True) - user = models.OneToOneField(to=User, on_delete=models.CASCADE, related_name='user_key', editable=False) + user = models.OneToOneField( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='user_key', editable=False + ) public_key = models.TextField( verbose_name='RSA public key', ) @@ -60,6 +61,9 @@ def __init__(self, *args, **kwargs): def __str__(self): return self.user.username + def get_absolute_url(self): + return reverse('plugins:netbox_secrets:userkey', args=[self.pk]) + def clean(self): super().clean() @@ -118,6 +122,27 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + + # Censor any backend parameters marked as sensitive in the serialized data + pre_change_params = {} + post_change_params = {} + if objectchange.prechange_data: + pre_change_params = objectchange.prechange_data + if objectchange.postchange_data: + post_change_params = objectchange.postchange_data + if post_change_params.get("master_key_cipher"): + if post_change_params["master_key_cipher"] != pre_change_params.get("master_key_cipher"): + # Set the "changed" master_key_cipher if the parameter's value has been modified + post_change_params["master_key_cipher"] = CENSOR_MASTER_KEY_CHANGED + else: + post_change_params["master_key_cipher"] = CENSOR_MASTER_KEY + if pre_change_params.get("master_key_cipher"): + pre_change_params["master_key_cipher"] = CENSOR_MASTER_KEY + + return objectchange + def is_filled(self): """ Returns True if the UserKey has been filled with a public RSA key. diff --git a/netbox_secrets/navigation.py b/netbox_secrets/navigation.py index b15af8c..4bf95f1 100644 --- a/netbox_secrets/navigation.py +++ b/netbox_secrets/navigation.py @@ -1,13 +1,13 @@ from django.conf import settings -from extras.plugins import PluginMenuButton, PluginMenuItem, PluginMenu -from utilities.choices import ButtonColorChoices + +from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem plugins_settings = settings.PLUGINS_CONFIG.get('netbox_secrets') menu_buttons = ( PluginMenuItem( - link_text="User Key", - link="plugins:netbox_secrets:userkey", + link_text="User Keys", + link="plugins:netbox_secrets:userkey_list", permissions=["netbox_secrets.view_userkey"], ), PluginMenuItem( @@ -19,14 +19,12 @@ link="plugins:netbox_secrets:secretrole_add", title="Add Secret Role", icon_class="mdi mdi-plus-thick", - color=ButtonColorChoices.GREEN, permissions=["netbox_secrets.add_secretrole"], ), PluginMenuButton( link="plugins:netbox_secrets:secretrole_import", title="Import Secret Role", icon_class="mdi mdi-upload", - color=ButtonColorChoices.CYAN, permissions=["netbox_secrets.add_secretrole"], ), ), diff --git a/netbox_secrets/project-static/src/secrets.ts b/netbox_secrets/project-static/src/secrets.ts index daed7b1..c206ed2 100644 --- a/netbox_secrets/project-static/src/secrets.ts +++ b/netbox_secrets/project-static/src/secrets.ts @@ -1,7 +1,7 @@ import { createToast } from './bs'; -import { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util'; +import { apiGetBase, apiPostForm, hasError, isApiError, isInputElement } from './util'; -import type { APISecret, APIKeyPair } from './types'; +import type { APIKeyPair, APISecret } from './types'; /** * Initialize Generate Private Key Pair Elements. @@ -9,8 +9,10 @@ import type { APISecret, APIKeyPair } from './types'; function initGenerateKeyPair() { const element = document.getElementById('new_keypair_modal') as HTMLDivElement; const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement; + const copyBtn = document.getElementById('copy_prikey') as HTMLButtonElement; + const exportBtn = document.getElementById('export_key') as HTMLButtonElement; // If the elements are not loaded, stop. - if (element === null || accept === null) { + if (element === null || accept === null || copyBtn === null || exportBtn === null) { return; } const publicElem = element.querySelector('textarea#new_pubkey'); @@ -54,8 +56,31 @@ function initGenerateKeyPair() { publicKeyField.innerText = publicElem.value; } } + + /** + * Handles file download functionality. + */ + function handleExport() { + const content = `Public Key\n\n${publicElem?.value}\n\nPrivate Key\n\n${privateElem?.value}`; + + const blob = new Blob([content], { type: 'text/plain' }); + + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = window.URL.createObjectURL(blob); + a.download = 'key.txt'; + document.body.appendChild(a); + + a.click(); + + window.URL.revokeObjectURL(a.href); + document.body.removeChild(a); + } + element.addEventListener('shown.bs.modal', () => handleOpen()); accept.addEventListener('click', () => handleAccept()); + copyBtn.addEventListener('click', () => navigator.clipboard.writeText(privateElem?.value || '')); + exportBtn.addEventListener('click', () => handleExport()); } /** diff --git a/netbox_secrets/querysets.py b/netbox_secrets/querysets.py index dc262a6..e1769e4 100644 --- a/netbox_secrets/querysets.py +++ b/netbox_secrets/querysets.py @@ -1,7 +1,7 @@ -from django.db.models import QuerySet +from utilities.querysets import RestrictedQuerySet -class UserKeyQuerySet(QuerySet): +class UserKeyQuerySet(RestrictedQuerySet): def active(self): return self.filter(master_key_cipher__isnull=False) diff --git a/netbox_secrets/static/netbox_secrets/secrets.js b/netbox_secrets/static/netbox_secrets/secrets.js index 424c14e..1c5540c 100644 --- a/netbox_secrets/static/netbox_secrets/secrets.js +++ b/netbox_secrets/static/netbox_secrets/secrets.js @@ -1,6 +1,12 @@ -"use strict";(()=>{var P=Object.create;var h=Object.defineProperty;var M=Object.getOwnPropertyDescriptor;var R=Object.getOwnPropertyNames;var C=Object.getPrototypeOf,D=Object.prototype.hasOwnProperty;var _=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports);var H=(e,r,o,t)=>{if(r&&typeof r=="object"||typeof r=="function")for(let i of R(r))!D.call(e,i)&&i!==o&&h(e,i,{get:()=>r[i],enumerable:!(t=M(r,i))||t.enumerable});return e};var B=(e,r,o)=>(o=e!=null?P(C(e)):{},H(r||!e||!e.__esModule?h(o,"default",{value:e,enumerable:!0}):o,e));var p=(e,r,o)=>new Promise((t,i)=>{var s=c=>{try{a(o.next(c))}catch(l){i(l)}},n=c=>{try{a(o.throw(c))}catch(l){i(l)}},a=c=>c.done?t(c.value):Promise.resolve(c.value).then(s,n);a((o=o.apply(e,r)).next())});var b=_(E=>{"use strict";E.parse=O;E.serialize=q;var I=Object.prototype.toString,m=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function O(e,r){if(typeof e!="string")throw new TypeError("argument str must be a string");for(var o={},t=r||{},i=t.decode||$,s=0;s{if(f(n))d("danger","Error",n.error).show();else{let{private_key:a,public_key:c}=n;o!==null&&t!==null&&(o.value=c,t.value=a)}})}function s(){let n=document.getElementById("id_public_key");o!==null&&(n.value=o.value,n.innerText=o.value)}e.addEventListener("shown.bs.modal",()=>i()),r.addEventListener("click",()=>s())}function L(e,r){let o=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),i=document.querySelector(`span[secret-id='${e}']`);console.log(i),o!==null&&(r==="unlock"&&o.classList.add("d-none"),r==="lock"&&o.classList.remove("d-none")),t!==null&&(r==="unlock"&&t.classList.remove("d-none"),r==="lock"&&t.classList.add("d-none")),i!==null&&(r==="unlock"&&i.classList.remove("d-none"),r==="lock"&&i.classList.add("d-none"))}function U(){let e=new window.Modal("#privkey_modal");function r(t){let i=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&k(`/api/plugins/secrets/secrets/${t}/`).then(s=>{if(f(s))s.error.toLowerCase().includes("invalid session key")?e.show():d("danger","Error",s.error).show();else{let{plaintext:n}=s;i!==null&&n!==null?(v(i)?i.value=n:i.innerText=n,L(t,"unlock")):e.show()}})}function o(t){if(typeof t=="string"&&t!==""){let i=document.getElementById(`secret_${t}`);v(i)?i.value="********":i.innerText="********",L(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>r(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>o(t.getAttribute("secret-id")))}function z(e){x("/api/plugins/secrets/session-keys/",{private_key:e,preserve_key:!0}).then(r=>{if(!f(r))window.location.pathname==="/plugins/secrets/user-key/"?window.location.reload():d("success","Session Key Received","You may now unlock secrets.").show();else{let o=r.error;A(r)&&(o+=` -${r.exception}`),d("danger","Failed to Retrieve Session Key",o).show()}})}function J(){for(let r of document.querySelectorAll("#request_session_key")){let o=function(){for(let t of document.querySelectorAll("#user_privkey"))z(t.value),t.value=""};var e=o;r.addEventListener("click",o)}}function X(){let e=new window.Modal("#privkey_modal");function r(o){document.cookie.indexOf("netbox_secrets_sessionid")===-1&&(o.preventDefault(),e.show())}for(let o of document.querySelectorAll(".requires-session-key")){let t=o.closest("form");t!==null&&t.addEventListener("submit",r)}}function w(){for(let e of[G,U,J,X])e()}document.readyState!=="loading"?w():document.addEventListener("DOMContentLoaded",w);})(); +"use strict";(()=>{var P=Object.create;var g=Object.defineProperty;var R=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var B=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var _=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports);var D=(e,n,i,t)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of M(n))!C.call(e,o)&&o!==i&&g(e,o,{get:()=>n[o],enumerable:!(t=R(n,o))||t.enumerable});return e};var H=(e,n,i)=>(i=e!=null?P(B(e)):{},D(n||!e||!e.__esModule?g(i,"default",{value:e,enumerable:!0}):i,e));var m=(e,n,i)=>new Promise((t,o)=>{var s=l=>{try{c(i.next(l))}catch(a){o(a)}},r=l=>{try{c(i.throw(l))}catch(a){o(a)}},c=l=>l.done?t(l.value):Promise.resolve(l.value).then(s,r);c((i=i.apply(e,n)).next())});var h=_(E=>{"use strict";E.parse=O;E.serialize=q;var I=Object.prototype.toString,f=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function O(e,n){if(typeof e!="string")throw new TypeError("argument str must be a string");for(var i={},t=n||{},o=t.decode||$,s=0;s{if(y(a))p("danger","Error",a.error).show();else{let{private_key:d,public_key:u}=a;o!==null&&s!==null&&(o.value=u,s.value=d)}})}function c(){let a=document.getElementById("id_public_key");o!==null&&(a.value=o.value,a.innerText=o.value)}function l(){let a=`Public Key + +${o==null?void 0:o.value} + +Private Key + +${s==null?void 0:s.value}`,d=new Blob([a],{type:"text/plain"}),u=document.createElement("a");u.style.display="none",u.href=window.URL.createObjectURL(d),u.download="key.txt",document.body.appendChild(u),u.click(),window.URL.revokeObjectURL(u.href),document.body.removeChild(u)}e.addEventListener("shown.bs.modal",()=>r()),n.addEventListener("click",()=>c()),i.addEventListener("click",()=>navigator.clipboard.writeText((s==null?void 0:s.value)||"")),t.addEventListener("click",()=>l())}function S(e,n){let i=document.querySelector(`button.unlock-secret[secret-id='${e}']`),t=document.querySelector(`button.lock-secret[secret-id='${e}']`),o=document.querySelector(`span[secret-id='${e}']`);i!==null&&(n==="unlock"&&i.classList.add("d-none"),n==="lock"&&i.classList.remove("d-none")),t!==null&&(n==="unlock"&&t.classList.remove("d-none"),n==="lock"&&t.classList.add("d-none")),o!==null&&(n==="unlock"&&o.classList.remove("d-none"),n==="lock"&&o.classList.add("d-none"))}function G(){let e=new window.Modal("#privkey_modal");function n(t){let o=document.getElementById(`secret_${t}`);typeof t=="string"&&t!==""&&k(`/api/plugins/secrets/secrets/${t}/`).then(s=>{if(y(s))s.error.toLowerCase().includes("invalid session key")?e.show():p("danger","Error",s.error).show();else{let{plaintext:r}=s;o!==null&&r!==null?(v(o)?o.value=r:o.innerText=r,S(t,"unlock")):e.show()}})}function i(t){if(typeof t=="string"&&t!==""){let o=document.getElementById(`secret_${t}`);v(o)?o.value="********":o.innerText="********",S(t,"lock")}}for(let t of document.querySelectorAll("button.unlock-secret"))t.addEventListener("click",()=>n(t.getAttribute("secret-id")));for(let t of document.querySelectorAll("button.lock-secret"))t.addEventListener("click",()=>i(t.getAttribute("secret-id")))}function z(e){A("/api/plugins/secrets/session-keys/",{private_key:e,preserve_key:!0}).then(n=>{if(!y(n))window.location.pathname==="/plugins/secrets/user-key/"?window.location.reload():p("success","Session Key Received","You may now unlock secrets.").show();else{let i=n.error;L(n)&&(i+=` +${n.exception}`),p("danger","Failed to Retrieve Session Key",i).show()}})}function J(){for(let n of document.querySelectorAll("#request_session_key")){let i=function(){for(let t of document.querySelectorAll("#user_privkey"))z(t.value),t.value=""};var e=i;n.addEventListener("click",i)}}function X(){let e=new window.Modal("#privkey_modal");function n(i){document.cookie.indexOf("netbox_secrets_sessionid")===-1&&(i.preventDefault(),e.show())}for(let i of document.querySelectorAll(".requires-session-key")){let t=i.closest("form");t!==null&&t.addEventListener("submit",n)}}function w(){for(let e of[N,G,J,X])e()}document.readyState!=="loading"?w():document.addEventListener("DOMContentLoaded",w);})(); /*! Bundled license information: cookie/index.js: diff --git a/netbox_secrets/static/netbox_secrets/secrets.js.map b/netbox_secrets/static/netbox_secrets/secrets.js.map index cedd1d7..781a33d 100644 --- a/netbox_secrets/static/netbox_secrets/secrets.js.map +++ b/netbox_secrets/static/netbox_secrets/secrets.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../node_modules/cookie/index.js", "../src/bs.ts", "../src/util.ts", "../src/secrets.ts", "../src/index.ts"], - "sourcesContent": ["/*!\n * cookie\n * Copyright(c) 2012-2014 Roman Shtylman\n * Copyright(c) 2015 Douglas Christopher Wilson\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module exports.\n * @public\n */\n\nexports.parse = parse;\nexports.serialize = serialize;\n\n/**\n * Module variables.\n * @private\n */\n\nvar __toString = Object.prototype.toString\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar = VCHAR / obs-text\n * obs-text = %x80-FF\n */\n\nvar fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\n/**\n * Parse a cookie header.\n *\n * Parse the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n *\n * @param {string} str\n * @param {object} [options]\n * @return {object}\n * @public\n */\n\nfunction parse(str, options) {\n if (typeof str !== 'string') {\n throw new TypeError('argument str must be a string');\n }\n\n var obj = {}\n var opt = options || {};\n var dec = opt.decode || decode;\n\n var index = 0\n while (index < str.length) {\n var eqIdx = str.indexOf('=', index)\n\n // no more cookie pairs\n if (eqIdx === -1) {\n break\n }\n\n var endIdx = str.indexOf(';', index)\n\n if (endIdx === -1) {\n endIdx = str.length\n } else if (endIdx < eqIdx) {\n // backtrack on prior semicolon\n index = str.lastIndexOf(';', eqIdx - 1) + 1\n continue\n }\n\n var key = str.slice(index, eqIdx).trim()\n\n // only assign once\n if (undefined === obj[key]) {\n var val = str.slice(eqIdx + 1, endIdx).trim()\n\n // quoted values\n if (val.charCodeAt(0) === 0x22) {\n val = val.slice(1, -1)\n }\n\n obj[key] = tryDecode(val, dec);\n }\n\n index = endIdx + 1\n }\n\n return obj;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * serialize('foo', 'bar', { httpOnly: true })\n * => \"foo=bar; httpOnly\"\n *\n * @param {string} name\n * @param {string} val\n * @param {object} [options]\n * @return {string}\n * @public\n */\n\nfunction serialize(name, val, options) {\n var opt = options || {};\n var enc = opt.encode || encode;\n\n if (typeof enc !== 'function') {\n throw new TypeError('option encode is invalid');\n }\n\n if (!fieldContentRegExp.test(name)) {\n throw new TypeError('argument name is invalid');\n }\n\n var value = enc(val);\n\n if (value && !fieldContentRegExp.test(value)) {\n throw new TypeError('argument val is invalid');\n }\n\n var str = name + '=' + value;\n\n if (null != opt.maxAge) {\n var maxAge = opt.maxAge - 0;\n\n if (isNaN(maxAge) || !isFinite(maxAge)) {\n throw new TypeError('option maxAge is invalid')\n }\n\n str += '; Max-Age=' + Math.floor(maxAge);\n }\n\n if (opt.domain) {\n if (!fieldContentRegExp.test(opt.domain)) {\n throw new TypeError('option domain is invalid');\n }\n\n str += '; Domain=' + opt.domain;\n }\n\n if (opt.path) {\n if (!fieldContentRegExp.test(opt.path)) {\n throw new TypeError('option path is invalid');\n }\n\n str += '; Path=' + opt.path;\n }\n\n if (opt.expires) {\n var expires = opt.expires\n\n if (!isDate(expires) || isNaN(expires.valueOf())) {\n throw new TypeError('option expires is invalid');\n }\n\n str += '; Expires=' + expires.toUTCString()\n }\n\n if (opt.httpOnly) {\n str += '; HttpOnly';\n }\n\n if (opt.secure) {\n str += '; Secure';\n }\n\n if (opt.priority) {\n var priority = typeof opt.priority === 'string'\n ? opt.priority.toLowerCase()\n : opt.priority\n\n switch (priority) {\n case 'low':\n str += '; Priority=Low'\n break\n case 'medium':\n str += '; Priority=Medium'\n break\n case 'high':\n str += '; Priority=High'\n break\n default:\n throw new TypeError('option priority is invalid')\n }\n }\n\n if (opt.sameSite) {\n var sameSite = typeof opt.sameSite === 'string'\n ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n switch (sameSite) {\n case true:\n str += '; SameSite=Strict';\n break;\n case 'lax':\n str += '; SameSite=Lax';\n break;\n case 'strict':\n str += '; SameSite=Strict';\n break;\n case 'none':\n str += '; SameSite=None';\n break;\n default:\n throw new TypeError('option sameSite is invalid');\n }\n }\n\n return str;\n}\n\n/**\n * URL-decode string value. Optimized to skip native call when no %.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction decode (str) {\n return str.indexOf('%') !== -1\n ? decodeURIComponent(str)\n : str\n}\n\n/**\n * URL-encode value.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction encode (val) {\n return encodeURIComponent(val)\n}\n\n/**\n * Determine if value is a Date.\n *\n * @param {*} val\n * @private\n */\n\nfunction isDate (val) {\n return __toString.call(val) === '[object Date]' ||\n val instanceof Date\n}\n\n/**\n * Try decoding a string using a decoding function.\n *\n * @param {string} str\n * @param {function} decode\n * @private\n */\n\nfunction tryDecode(str, decode) {\n try {\n return decode(str);\n } catch (e) {\n return str;\n }\n}\n", "type ToastLevel = 'danger' | 'warning' | 'success' | 'info';\n\nexport function createToast(\n level: ToastLevel,\n title: string,\n message: string,\n extra?: string,\n): InstanceType {\n let iconName = 'mdi-alert';\n switch (level) {\n case 'warning':\n iconName = 'mdi-alert';\n break;\n case 'success':\n iconName = 'mdi-check-circle';\n break;\n case 'info':\n iconName = 'mdi-information';\n break;\n case 'danger':\n iconName = 'mdi-alert';\n break;\n }\n\n const container = document.createElement('div');\n container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');\n\n const main = document.createElement('div');\n main.setAttribute('class', `toast bg-${level}`);\n main.setAttribute('role', 'alert');\n main.setAttribute('aria-live', 'assertive');\n main.setAttribute('aria-atomic', 'true');\n\n const header = document.createElement('div');\n header.setAttribute('class', `toast-header bg-${level} text-body`);\n\n const icon = document.createElement('i');\n icon.setAttribute('class', `mdi ${iconName}`);\n\n const titleElement = document.createElement('strong');\n titleElement.setAttribute('class', 'me-auto ms-1');\n titleElement.innerText = title;\n\n const button = document.createElement('button');\n button.setAttribute('type', 'button');\n button.setAttribute('class', 'btn-close');\n button.setAttribute('data-bs-dismiss', 'toast');\n button.setAttribute('aria-label', 'Close');\n\n const body = document.createElement('div');\n body.setAttribute('class', 'toast-body');\n\n header.appendChild(icon);\n header.appendChild(titleElement);\n\n if (typeof extra !== 'undefined') {\n const extraElement = document.createElement('small');\n extraElement.setAttribute('class', 'text-muted');\n header.appendChild(extraElement);\n }\n\n header.appendChild(button);\n\n body.innerText = message.trim();\n\n main.appendChild(header);\n main.appendChild(body);\n container.appendChild(main);\n document.body.appendChild(container);\n\n const toast = new window.Toast(main);\n return toast;\n}\n", "import Cookie from 'cookie';\n\ntype APIRes = T | ErrorBase | APIError;\ntype Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\ntype ReqData = URLSearchParams | Dict | undefined | unknown;\n\n/**\n * Type guard to determine if an API response is a detailed error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function isApiError(data: Record): data is APIError {\n return 'error' in data && 'exception' in data;\n}\n\n/**\n * Type guard to determine if an API response is an error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function hasError(data: Record): data is ErrorBase {\n return 'error' in data;\n}\n\n/**\n * Type guard to determine if an element is an `HTMLInputElement`.\n *\n * @param element HTML Element.\n */\nexport function isInputElement(element: HTMLElement): element is HTMLInputElement {\n return 'value' in element && 'required' in element;\n}\n\n/**\n * Retrieve the CSRF token from cookie storage.\n */\nexport function getCsrfToken(): string {\n const { csrftoken: csrfToken } = Cookie.parse(document.cookie);\n if (typeof csrfToken === 'undefined') {\n throw new Error('Invalid or missing CSRF token');\n }\n return csrfToken;\n}\n\n/**\n * Authenticate and interact with the NetBox API.\n *\n * @param url Request URL\n * @param method Request Method\n * @param data Data to `POST`, `PATCH`, or `PUT`, if applicable.\n * @returns JSON Response\n */\nexport async function apiRequest(\n url: string,\n method: Method,\n data?: D,\n): Promise> {\n const token = getCsrfToken();\n const headers = new Headers({ 'X-CSRFToken': token });\n\n let body;\n if (typeof data !== 'undefined') {\n body = JSON.stringify(data);\n headers.set('content-type', 'application/json');\n headers.set('Accept', 'application/json');\n }\n\n const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });\n const contentType = res.headers.get('Content-Type');\n if (typeof contentType === 'string' && contentType.includes('text')) {\n const error = await res.text();\n return { error } as ErrorBase;\n }\n const json = (await res.json()) as R | APIError;\n if (!res.ok && Array.isArray(json)) {\n const error = json.join('\\n');\n return { error } as ErrorBase;\n } else if (!res.ok && 'detail' in json) {\n return { error: json.detail } as ErrorBase;\n }\n return json;\n}\n\n/**\n * `POST` an object as form data to the NetBox API.\n *\n * @param url Request URL\n * @param data Object to convert to form data\n * @returns JSON Response\n */\nexport async function apiPostForm(\n url: string,\n data: D,\n): Promise> {\n return await apiRequest(url, 'POST', data);\n}\n\n/**\n * `GET` data from the NetBox API.\n *\n * @param url Request URL\n * @returns JSON Response\n */\nexport async function apiGetBase(url: string): Promise> {\n return await apiRequest(url, 'GET');\n}\n", "import { createToast } from './bs';\nimport { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';\n\nimport type { APISecret, APIKeyPair } from './types';\n\n/**\n * Initialize Generate Private Key Pair Elements.\n */\nfunction initGenerateKeyPair() {\n const element = document.getElementById('new_keypair_modal') as HTMLDivElement;\n const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;\n // If the elements are not loaded, stop.\n if (element === null || accept === null) {\n return;\n }\n const publicElem = element.querySelector('textarea#new_pubkey');\n const privateElem = element.querySelector('textarea#new_privkey');\n\n /**\n * Handle Generate Private Key Pair Modal opening.\n */\n function handleOpen() {\n // When the modal opens, set the `readonly` attribute on the textarea elements.\n for (const elem of [publicElem, privateElem]) {\n if (elem !== null) {\n elem.setAttribute('readonly', '');\n }\n }\n // Fetch the key pair from the API.\n apiGetBase('/api/plugins/secrets/generate-rsa-key-pair/').then(data => {\n if (!hasError(data)) {\n // If key pair generation was successful, set the textarea elements' value to the generated\n // values.\n const { private_key: priv, public_key: pub } = data;\n if (publicElem !== null && privateElem !== null) {\n publicElem.value = pub;\n privateElem.value = priv;\n }\n } else {\n // Otherwise, show an error.\n const toast = createToast('danger', 'Error', data.error);\n toast.show();\n }\n });\n }\n\n /**\n * Set the public key form field's value to the generated public key.\n */\n function handleAccept() {\n const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;\n if (publicElem !== null) {\n publicKeyField.value = publicElem.value;\n publicKeyField.innerText = publicElem.value;\n }\n }\n element.addEventListener('shown.bs.modal', () => handleOpen());\n accept.addEventListener('click', () => handleAccept());\n}\n\n/**\n * Toggle copy/lock/unlock button visibility based on the action occurring.\n * @param id Secret ID.\n * @param action Lock or Unlock, so we know which buttons to display.\n */\nfunction toggleSecretButtons(id: string, action: 'lock' | 'unlock') {\n const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);\n const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);\n const copyButton = document.querySelector(`span[secret-id='${id}']`);\n console.log(copyButton);\n // If we're unlocking, hide the unlock button. Otherwise, show it.\n if (unlockButton !== null) {\n if (action === 'unlock') unlockButton.classList.add('d-none');\n if (action === 'lock') unlockButton.classList.remove('d-none');\n }\n // If we're unlocking, show the lock button. Otherwise, hide it.\n if (lockButton !== null) {\n if (action === 'unlock') lockButton.classList.remove('d-none');\n if (action === 'lock') lockButton.classList.add('d-none');\n }\n // If we're unlocking, show the copy button. Otherwise, hide it.\n if (copyButton !== null) {\n if (action === 'unlock') copyButton.classList.remove('d-none');\n if (action === 'lock') copyButton.classList.add('d-none');\n }\n}\n\n/**\n * Initialize Lock & Unlock button event listeners & callbacks.\n */\nfunction initLockUnlock() {\n const privateKeyModal = new window.Modal('#privkey_modal');\n\n /**\n * Unlock a secret, or prompt the user for their private key, if a session key is not available.\n *\n * @param id Secret ID\n */\n function unlock(id: string | null) {\n const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n if (typeof id === 'string' && id !== '') {\n apiGetBase(`/api/plugins/secrets/secrets/${id}/`).then(data => {\n if (!hasError(data)) {\n const { plaintext } = data;\n // `plaintext` is the plain text value of the secret. If it is null, it has not been\n // decrypted, likely due to a mission session key.\n\n if (target !== null && plaintext !== null) {\n // If `plaintext` is not null, we have the decrypted value. Set the target element's\n // inner text to the decrypted value and toggle copy/lock button visibility.\n if (isInputElement(target)) {\n target.value = plaintext;\n } else {\n target.innerText = plaintext;\n }\n\n toggleSecretButtons(id, 'unlock');\n } else {\n // Otherwise, we do _not_ have the decrypted value and need to prompt the user for\n // their private RSA key, in order to get a session key. The session key is then sent\n // as a cookie in future requests.\n privateKeyModal.show();\n }\n } else {\n if (data.error.toLowerCase().includes('invalid session key')) {\n // If, for some reason, a request was made but resulted in an API error that complains\n // of a missing session key, prompt the user for their session key.\n privateKeyModal.show();\n } else {\n // If we received an API error but it doesn't contain 'invalid session key', show the\n // user an error message.\n const toast = createToast('danger', 'Error', data.error);\n toast.show();\n }\n }\n });\n }\n }\n\n /**\n * Lock a secret and toggle visibility of the unlock button.\n * @param id Secret ID\n */\n function lock(id: string | null) {\n if (typeof id === 'string' && id !== '') {\n const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n\n // Obscure the inner text of the secret element.\n if (isInputElement(target)) {\n target.value = '********';\n } else {\n target.innerText = '********';\n }\n\n // Toggle visibility of the copy/lock/unlock buttons.\n toggleSecretButtons(id, 'lock');\n }\n }\n\n for (const element of document.querySelectorAll('button.unlock-secret')) {\n element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));\n }\n for (const element of document.querySelectorAll('button.lock-secret')) {\n element.addEventListener('click', () => lock(element.getAttribute('secret-id')));\n }\n}\n\n/**\n * Request a session key from the API.\n * @param privateKey RSA Private Key (valid JSON string)\n */\nfunction requestSessionKey(privateKey: string) {\n apiPostForm('/api/plugins/secrets/session-keys/', {\n private_key: privateKey,\n preserve_key: true,\n }).then(res => {\n if (!hasError(res)) {\n // If the session key has been added from the user key page, reload the page.\n if (window.location.pathname === '/plugins/secrets/user-key/') {\n window.location.reload();\n } else {\n // If the response received was not an error, show the user a success message.\n const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');\n toast.show();\n }\n } else {\n // Otherwise, show the user an error message.\n let message = res.error;\n if (isApiError(res)) {\n // If the error received was a standard API error containing a Python exception message,\n // append it to the error.\n message += `\\n${res.exception}`;\n }\n const toast = createToast('danger', 'Failed to Retrieve Session Key', message);\n toast.show();\n }\n });\n}\n\n/**\n * Initialize Request Session Key Elements.\n */\nfunction initGetSessionKey() {\n for (const element of document.querySelectorAll('#request_session_key')) {\n /**\n * Send the user's input private key to the API to get a session key, which will be stored as\n * a cookie for future requests.\n */\n function handleClick() {\n for (const pk of document.querySelectorAll('#user_privkey')) {\n requestSessionKey(pk.value);\n // Clear the private key form field value.\n pk.value = '';\n }\n }\n element.addEventListener('click', handleClick);\n }\n}\n\n/**\n * Initialize Secret Edit Form Handler.\n */\nfunction initSecretsEdit() {\n const privateKeyModal = new window.Modal('#privkey_modal');\n\n /**\n * Check the cookie store for a `netbox_secrets_sessionid`. If not present, prompt the user to submit their\n * private key.\n */\n function handleSubmit(event: Event): void {\n if (document.cookie.indexOf('netbox_secrets_sessionid') === -1) {\n event.preventDefault();\n privateKeyModal.show();\n }\n }\n\n for (const element of document.querySelectorAll('.requires-session-key')) {\n const form = element.closest('form');\n if (form !== null) {\n form.addEventListener('submit', handleSubmit);\n }\n }\n}\n\nexport function initSecrets() {\n for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey, initSecretsEdit]) {\n func();\n }\n}\n", "import { initSecrets } from './secrets';\n\nif (document.readyState !== 'loading') {\n initSecrets();\n} else {\n document.addEventListener('DOMContentLoaded', initSecrets);\n}\n"], - "mappings": "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", - "names": ["require_cookie", "__commonJSMin", "exports", "parse", "serialize", "__toString", "fieldContentRegExp", "str", "options", "obj", "opt", "dec", "decode", "index", "eqIdx", "endIdx", "key", "val", "tryDecode", "name", "enc", "encode", "value", "maxAge", "expires", "isDate", "priority", "sameSite", "e", "createToast", "level", "title", "message", "extra", "iconName", "container", "main", "header", "icon", "titleElement", "button", "body", "extraElement", "import_cookie", "isApiError", "data", "hasError", "isInputElement", "element", "getCsrfToken", "csrfToken", "Cookie", "apiRequest", "url", "method", "__async", "token", "headers", "body", "res", "contentType", "json", "apiPostForm", "apiGetBase", "initGenerateKeyPair", "element", "accept", "publicElem", "privateElem", "handleOpen", "elem", "apiGetBase", "data", "hasError", "createToast", "priv", "pub", "handleAccept", "publicKeyField", "toggleSecretButtons", "id", "action", "unlockButton", "lockButton", "copyButton", "initLockUnlock", "privateKeyModal", "unlock", "target", "plaintext", "isInputElement", "lock", "requestSessionKey", "privateKey", "apiPostForm", "res", "message", "isApiError", "initGetSessionKey", "handleClick", "pk", "initSecretsEdit", "handleSubmit", "event", "form", "initSecrets", "func", "initSecrets"] + "sourcesContent": ["/*!\n * cookie\n * Copyright(c) 2012-2014 Roman Shtylman\n * Copyright(c) 2015 Douglas Christopher Wilson\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module exports.\n * @public\n */\n\nexports.parse = parse;\nexports.serialize = serialize;\n\n/**\n * Module variables.\n * @private\n */\n\nvar __toString = Object.prototype.toString\n\n/**\n * RegExp to match field-content in RFC 7230 sec 3.2\n *\n * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]\n * field-vchar = VCHAR / obs-text\n * obs-text = %x80-FF\n */\n\nvar fieldContentRegExp = /^[\\u0009\\u0020-\\u007e\\u0080-\\u00ff]+$/;\n\n/**\n * Parse a cookie header.\n *\n * Parse the given cookie header string into an object\n * The object has the various cookies as keys(names) => values\n *\n * @param {string} str\n * @param {object} [options]\n * @return {object}\n * @public\n */\n\nfunction parse(str, options) {\n if (typeof str !== 'string') {\n throw new TypeError('argument str must be a string');\n }\n\n var obj = {}\n var opt = options || {};\n var dec = opt.decode || decode;\n\n var index = 0\n while (index < str.length) {\n var eqIdx = str.indexOf('=', index)\n\n // no more cookie pairs\n if (eqIdx === -1) {\n break\n }\n\n var endIdx = str.indexOf(';', index)\n\n if (endIdx === -1) {\n endIdx = str.length\n } else if (endIdx < eqIdx) {\n // backtrack on prior semicolon\n index = str.lastIndexOf(';', eqIdx - 1) + 1\n continue\n }\n\n var key = str.slice(index, eqIdx).trim()\n\n // only assign once\n if (undefined === obj[key]) {\n var val = str.slice(eqIdx + 1, endIdx).trim()\n\n // quoted values\n if (val.charCodeAt(0) === 0x22) {\n val = val.slice(1, -1)\n }\n\n obj[key] = tryDecode(val, dec);\n }\n\n index = endIdx + 1\n }\n\n return obj;\n}\n\n/**\n * Serialize data into a cookie header.\n *\n * Serialize the a name value pair into a cookie string suitable for\n * http headers. An optional options object specified cookie parameters.\n *\n * serialize('foo', 'bar', { httpOnly: true })\n * => \"foo=bar; httpOnly\"\n *\n * @param {string} name\n * @param {string} val\n * @param {object} [options]\n * @return {string}\n * @public\n */\n\nfunction serialize(name, val, options) {\n var opt = options || {};\n var enc = opt.encode || encode;\n\n if (typeof enc !== 'function') {\n throw new TypeError('option encode is invalid');\n }\n\n if (!fieldContentRegExp.test(name)) {\n throw new TypeError('argument name is invalid');\n }\n\n var value = enc(val);\n\n if (value && !fieldContentRegExp.test(value)) {\n throw new TypeError('argument val is invalid');\n }\n\n var str = name + '=' + value;\n\n if (null != opt.maxAge) {\n var maxAge = opt.maxAge - 0;\n\n if (isNaN(maxAge) || !isFinite(maxAge)) {\n throw new TypeError('option maxAge is invalid')\n }\n\n str += '; Max-Age=' + Math.floor(maxAge);\n }\n\n if (opt.domain) {\n if (!fieldContentRegExp.test(opt.domain)) {\n throw new TypeError('option domain is invalid');\n }\n\n str += '; Domain=' + opt.domain;\n }\n\n if (opt.path) {\n if (!fieldContentRegExp.test(opt.path)) {\n throw new TypeError('option path is invalid');\n }\n\n str += '; Path=' + opt.path;\n }\n\n if (opt.expires) {\n var expires = opt.expires\n\n if (!isDate(expires) || isNaN(expires.valueOf())) {\n throw new TypeError('option expires is invalid');\n }\n\n str += '; Expires=' + expires.toUTCString()\n }\n\n if (opt.httpOnly) {\n str += '; HttpOnly';\n }\n\n if (opt.secure) {\n str += '; Secure';\n }\n\n if (opt.priority) {\n var priority = typeof opt.priority === 'string'\n ? opt.priority.toLowerCase()\n : opt.priority\n\n switch (priority) {\n case 'low':\n str += '; Priority=Low'\n break\n case 'medium':\n str += '; Priority=Medium'\n break\n case 'high':\n str += '; Priority=High'\n break\n default:\n throw new TypeError('option priority is invalid')\n }\n }\n\n if (opt.sameSite) {\n var sameSite = typeof opt.sameSite === 'string'\n ? opt.sameSite.toLowerCase() : opt.sameSite;\n\n switch (sameSite) {\n case true:\n str += '; SameSite=Strict';\n break;\n case 'lax':\n str += '; SameSite=Lax';\n break;\n case 'strict':\n str += '; SameSite=Strict';\n break;\n case 'none':\n str += '; SameSite=None';\n break;\n default:\n throw new TypeError('option sameSite is invalid');\n }\n }\n\n return str;\n}\n\n/**\n * URL-decode string value. Optimized to skip native call when no %.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction decode (str) {\n return str.indexOf('%') !== -1\n ? decodeURIComponent(str)\n : str\n}\n\n/**\n * URL-encode value.\n *\n * @param {string} str\n * @returns {string}\n */\n\nfunction encode (val) {\n return encodeURIComponent(val)\n}\n\n/**\n * Determine if value is a Date.\n *\n * @param {*} val\n * @private\n */\n\nfunction isDate (val) {\n return __toString.call(val) === '[object Date]' ||\n val instanceof Date\n}\n\n/**\n * Try decoding a string using a decoding function.\n *\n * @param {string} str\n * @param {function} decode\n * @private\n */\n\nfunction tryDecode(str, decode) {\n try {\n return decode(str);\n } catch (e) {\n return str;\n }\n}\n", "type ToastLevel = 'danger' | 'warning' | 'success' | 'info';\n\nexport function createToast(\n level: ToastLevel,\n title: string,\n message: string,\n extra?: string,\n): InstanceType {\n let iconName = 'mdi-alert';\n switch (level) {\n case 'warning':\n iconName = 'mdi-alert';\n break;\n case 'success':\n iconName = 'mdi-check-circle';\n break;\n case 'info':\n iconName = 'mdi-information';\n break;\n case 'danger':\n iconName = 'mdi-alert';\n break;\n }\n\n const container = document.createElement('div');\n container.setAttribute('class', 'toast-container position-fixed bottom-0 end-0 m-3');\n\n const main = document.createElement('div');\n main.setAttribute('class', `toast bg-${level}`);\n main.setAttribute('role', 'alert');\n main.setAttribute('aria-live', 'assertive');\n main.setAttribute('aria-atomic', 'true');\n\n const header = document.createElement('div');\n header.setAttribute('class', `toast-header bg-${level} text-body`);\n\n const icon = document.createElement('i');\n icon.setAttribute('class', `mdi ${iconName}`);\n\n const titleElement = document.createElement('strong');\n titleElement.setAttribute('class', 'me-auto ms-1');\n titleElement.innerText = title;\n\n const button = document.createElement('button');\n button.setAttribute('type', 'button');\n button.setAttribute('class', 'btn-close');\n button.setAttribute('data-bs-dismiss', 'toast');\n button.setAttribute('aria-label', 'Close');\n\n const body = document.createElement('div');\n body.setAttribute('class', 'toast-body');\n\n header.appendChild(icon);\n header.appendChild(titleElement);\n\n if (typeof extra !== 'undefined') {\n const extraElement = document.createElement('small');\n extraElement.setAttribute('class', 'text-muted');\n header.appendChild(extraElement);\n }\n\n header.appendChild(button);\n\n body.innerText = message.trim();\n\n main.appendChild(header);\n main.appendChild(body);\n container.appendChild(main);\n document.body.appendChild(container);\n\n const toast = new window.Toast(main);\n return toast;\n}\n", "import Cookie from 'cookie';\n\ntype APIRes = T | ErrorBase | APIError;\ntype Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';\ntype ReqData = URLSearchParams | Dict | undefined | unknown;\n\n/**\n * Type guard to determine if an API response is a detailed error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function isApiError(data: Record): data is APIError {\n return 'error' in data && 'exception' in data;\n}\n\n/**\n * Type guard to determine if an API response is an error.\n *\n * @param data API JSON Response\n * @returns Type guard for `data`.\n */\nexport function hasError(data: Record): data is ErrorBase {\n return 'error' in data;\n}\n\n/**\n * Type guard to determine if an element is an `HTMLInputElement`.\n *\n * @param element HTML Element.\n */\nexport function isInputElement(element: HTMLElement): element is HTMLInputElement {\n return 'value' in element && 'required' in element;\n}\n\n/**\n * Retrieve the CSRF token from cookie storage.\n */\nexport function getCsrfToken(): string {\n const { csrftoken: csrfToken } = Cookie.parse(document.cookie);\n if (typeof csrfToken === 'undefined') {\n throw new Error('Invalid or missing CSRF token');\n }\n return csrfToken;\n}\n\n/**\n * Authenticate and interact with the NetBox API.\n *\n * @param url Request URL\n * @param method Request Method\n * @param data Data to `POST`, `PATCH`, or `PUT`, if applicable.\n * @returns JSON Response\n */\nexport async function apiRequest(\n url: string,\n method: Method,\n data?: D,\n): Promise> {\n const token = getCsrfToken();\n const headers = new Headers({ 'X-CSRFToken': token });\n\n let body;\n if (typeof data !== 'undefined') {\n body = JSON.stringify(data);\n headers.set('content-type', 'application/json');\n headers.set('Accept', 'application/json');\n }\n\n const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });\n const contentType = res.headers.get('Content-Type');\n if (typeof contentType === 'string' && contentType.includes('text')) {\n const error = await res.text();\n return { error } as ErrorBase;\n }\n const json = (await res.json()) as R | APIError;\n if (!res.ok && Array.isArray(json)) {\n const error = json.join('\\n');\n return { error } as ErrorBase;\n } else if (!res.ok && 'detail' in json) {\n return { error: json.detail } as ErrorBase;\n }\n return json;\n}\n\n/**\n * `POST` an object as form data to the NetBox API.\n *\n * @param url Request URL\n * @param data Object to convert to form data\n * @returns JSON Response\n */\nexport async function apiPostForm(\n url: string,\n data: D,\n): Promise> {\n return await apiRequest(url, 'POST', data);\n}\n\n/**\n * `GET` data from the NetBox API.\n *\n * @param url Request URL\n * @returns JSON Response\n */\nexport async function apiGetBase(url: string): Promise> {\n return await apiRequest(url, 'GET');\n}\n", "import { createToast } from './bs';\nimport { apiGetBase, apiPostForm, isApiError, isInputElement, hasError } from './util';\n\nimport type { APISecret, APIKeyPair } from './types';\n\n/**\n * Initialize Generate Private Key Pair Elements.\n */\nfunction initGenerateKeyPair() {\n const element = document.getElementById('new_keypair_modal') as HTMLDivElement;\n const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;\n const copyBtn = document.getElementById('copy_prikey') as HTMLButtonElement;\n const exportBtn = document.getElementById('export_key') as HTMLButtonElement;\n // If the elements are not loaded, stop.\n if (element === null || accept === null || copyBtn === null || exportBtn === null) {\n return;\n }\n const publicElem = element.querySelector('textarea#new_pubkey');\n const privateElem = element.querySelector('textarea#new_privkey');\n\n /**\n * Handle Generate Private Key Pair Modal opening.\n */\n function handleOpen() {\n // When the modal opens, set the `readonly` attribute on the textarea elements.\n for (const elem of [publicElem, privateElem]) {\n if (elem !== null) {\n elem.setAttribute('readonly', '');\n }\n }\n // Fetch the key pair from the API.\n apiGetBase('/api/plugins/secrets/generate-rsa-key-pair/').then(data => {\n if (!hasError(data)) {\n // If key pair generation was successful, set the textarea elements' value to the generated\n // values.\n const { private_key: priv, public_key: pub } = data;\n if (publicElem !== null && privateElem !== null) {\n publicElem.value = pub;\n privateElem.value = priv;\n }\n } else {\n // Otherwise, show an error.\n const toast = createToast('danger', 'Error', data.error);\n toast.show();\n }\n });\n }\n\n /**\n * Set the public key form field's value to the generated public key.\n */\n function handleAccept() {\n const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;\n if (publicElem !== null) {\n publicKeyField.value = publicElem.value;\n publicKeyField.innerText = publicElem.value;\n }\n }\n\n /**\n * Handles file download functionality.\n */\n function handleExport() {\n const content = `Public Key\\n\\n${publicElem?.value}\\n\\nPrivate Key\\n\\n${privateElem?.value}`;\n\n const blob = new Blob([content], { type: 'text/plain' });\n\n const a = document.createElement('a');\n a.style.display = 'none';\n a.href = window.URL.createObjectURL(blob);\n a.download = 'key.txt';\n document.body.appendChild(a);\n\n a.click();\n\n window.URL.revokeObjectURL(a.href);\n document.body.removeChild(a);\n }\n\n element.addEventListener('shown.bs.modal', () => handleOpen());\n accept.addEventListener('click', () => handleAccept());\n copyBtn.addEventListener('click', () => navigator.clipboard.writeText(privateElem?.value || ''));\n exportBtn.addEventListener('click', () => handleExport());\n}\n\n/**\n * Toggle copy/lock/unlock button visibility based on the action occurring.\n * @param id Secret ID.\n * @param action Lock or Unlock, so we know which buttons to display.\n */\nfunction toggleSecretButtons(id: string, action: 'lock' | 'unlock') {\n const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);\n const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);\n const copyButton = document.querySelector(`span[secret-id='${id}']`);\n // If we're unlocking, hide the unlock button. Otherwise, show it.\n if (unlockButton !== null) {\n if (action === 'unlock') unlockButton.classList.add('d-none');\n if (action === 'lock') unlockButton.classList.remove('d-none');\n }\n // If we're unlocking, show the lock button. Otherwise, hide it.\n if (lockButton !== null) {\n if (action === 'unlock') lockButton.classList.remove('d-none');\n if (action === 'lock') lockButton.classList.add('d-none');\n }\n // If we're unlocking, show the copy button. Otherwise, hide it.\n if (copyButton !== null) {\n if (action === 'unlock') copyButton.classList.remove('d-none');\n if (action === 'lock') copyButton.classList.add('d-none');\n }\n}\n\n/**\n * Initialize Lock & Unlock button event listeners & callbacks.\n */\nfunction initLockUnlock() {\n const privateKeyModal = new window.Modal('#privkey_modal');\n\n /**\n * Unlock a secret, or prompt the user for their private key, if a session key is not available.\n *\n * @param id Secret ID\n */\n function unlock(id: string | null) {\n const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n if (typeof id === 'string' && id !== '') {\n apiGetBase(`/api/plugins/secrets/secrets/${id}/`).then(data => {\n if (!hasError(data)) {\n const { plaintext } = data;\n // `plaintext` is the plain text value of the secret. If it is null, it has not been\n // decrypted, likely due to a mission session key.\n\n if (target !== null && plaintext !== null) {\n // If `plaintext` is not null, we have the decrypted value. Set the target element's\n // inner text to the decrypted value and toggle copy/lock button visibility.\n if (isInputElement(target)) {\n target.value = plaintext;\n } else {\n target.innerText = plaintext;\n }\n\n toggleSecretButtons(id, 'unlock');\n } else {\n // Otherwise, we do _not_ have the decrypted value and need to prompt the user for\n // their private RSA key, in order to get a session key. The session key is then sent\n // as a cookie in future requests.\n privateKeyModal.show();\n }\n } else {\n if (data.error.toLowerCase().includes('invalid session key')) {\n // If, for some reason, a request was made but resulted in an API error that complains\n // of a missing session key, prompt the user for their session key.\n privateKeyModal.show();\n } else {\n // If we received an API error but it doesn't contain 'invalid session key', show the\n // user an error message.\n const toast = createToast('danger', 'Error', data.error);\n toast.show();\n }\n }\n });\n }\n }\n\n /**\n * Lock a secret and toggle visibility of the unlock button.\n * @param id Secret ID\n */\n function lock(id: string | null) {\n if (typeof id === 'string' && id !== '') {\n const target = document.getElementById(`secret_${id}`) as HTMLDivElement | HTMLInputElement;\n\n // Obscure the inner text of the secret element.\n if (isInputElement(target)) {\n target.value = '********';\n } else {\n target.innerText = '********';\n }\n\n // Toggle visibility of the copy/lock/unlock buttons.\n toggleSecretButtons(id, 'lock');\n }\n }\n\n for (const element of document.querySelectorAll('button.unlock-secret')) {\n element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));\n }\n for (const element of document.querySelectorAll('button.lock-secret')) {\n element.addEventListener('click', () => lock(element.getAttribute('secret-id')));\n }\n}\n\n/**\n * Request a session key from the API.\n * @param privateKey RSA Private Key (valid JSON string)\n */\nfunction requestSessionKey(privateKey: string) {\n apiPostForm('/api/plugins/secrets/session-keys/', {\n private_key: privateKey,\n preserve_key: true,\n }).then(res => {\n if (!hasError(res)) {\n // If the session key has been added from the user key page, reload the page.\n if (window.location.pathname === '/plugins/secrets/user-key/') {\n window.location.reload();\n } else {\n // If the response received was not an error, show the user a success message.\n const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');\n toast.show();\n }\n } else {\n // Otherwise, show the user an error message.\n let message = res.error;\n if (isApiError(res)) {\n // If the error received was a standard API error containing a Python exception message,\n // append it to the error.\n message += `\\n${res.exception}`;\n }\n const toast = createToast('danger', 'Failed to Retrieve Session Key', message);\n toast.show();\n }\n });\n}\n\n/**\n * Initialize Request Session Key Elements.\n */\nfunction initGetSessionKey() {\n for (const element of document.querySelectorAll('#request_session_key')) {\n /**\n * Send the user's input private key to the API to get a session key, which will be stored as\n * a cookie for future requests.\n */\n function handleClick() {\n for (const pk of document.querySelectorAll('#user_privkey')) {\n requestSessionKey(pk.value);\n // Clear the private key form field value.\n pk.value = '';\n }\n }\n element.addEventListener('click', handleClick);\n }\n}\n\n/**\n * Initialize Secret Edit Form Handler.\n */\nfunction initSecretsEdit() {\n const privateKeyModal = new window.Modal('#privkey_modal');\n\n /**\n * Check the cookie store for a `netbox_secrets_sessionid`. If not present, prompt the user to submit their\n * private key.\n */\n function handleSubmit(event: Event): void {\n if (document.cookie.indexOf('netbox_secrets_sessionid') === -1) {\n event.preventDefault();\n privateKeyModal.show();\n }\n }\n\n for (const element of document.querySelectorAll('.requires-session-key')) {\n const form = element.closest('form');\n if (form !== null) {\n form.addEventListener('submit', handleSubmit);\n }\n }\n}\n\nexport function initSecrets() {\n for (const func of [initGenerateKeyPair, initLockUnlock, initGetSessionKey, initSecretsEdit]) {\n func();\n }\n}\n", "import { initSecrets } from './secrets';\n\nif (document.readyState !== 'loading') {\n initSecrets();\n} else {\n document.addEventListener('DOMContentLoaded', initSecrets);\n}\n"], + "mappings": "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", + "names": ["require_cookie", "__commonJSMin", "exports", "parse", "serialize", "__toString", "fieldContentRegExp", "str", "options", "obj", "opt", "dec", "decode", "index", "eqIdx", "endIdx", "key", "val", "tryDecode", "name", "enc", "encode", "value", "maxAge", "expires", "isDate", "priority", "sameSite", "e", "createToast", "level", "title", "message", "extra", "iconName", "container", "main", "header", "icon", "titleElement", "button", "body", "extraElement", "import_cookie", "isApiError", "data", "hasError", "isInputElement", "element", "getCsrfToken", "csrfToken", "Cookie", "apiRequest", "url", "method", "__async", "token", "headers", "body", "res", "contentType", "json", "apiPostForm", "apiGetBase", "initGenerateKeyPair", "element", "accept", "copyBtn", "exportBtn", "publicElem", "privateElem", "handleOpen", "elem", "apiGetBase", "data", "hasError", "createToast", "priv", "pub", "handleAccept", "publicKeyField", "handleExport", "content", "blob", "a", "toggleSecretButtons", "id", "action", "unlockButton", "lockButton", "copyButton", "initLockUnlock", "privateKeyModal", "unlock", "target", "plaintext", "isInputElement", "lock", "requestSessionKey", "privateKey", "apiPostForm", "res", "message", "isApiError", "initGetSessionKey", "handleClick", "pk", "initSecretsEdit", "handleSubmit", "event", "form", "initSecrets", "func", "initSecrets"] } diff --git a/netbox_secrets/tables.py b/netbox_secrets/tables.py index 8aa889d..1434fe8 100644 --- a/netbox_secrets/tables.py +++ b/netbox_secrets/tables.py @@ -1,7 +1,9 @@ import django_tables2 as tables +from django.utils.translation import gettext as _ + from netbox.tables import NetBoxTable, columns +from .models import Secret, SecretRole, UserKey -from .models import Secret, SecretRole # # Secret roles @@ -74,3 +76,30 @@ class Meta(NetBoxTable.Meta): 'role', 'actions', ) + + +class UserKeyTable(NetBoxTable): + user = tables.Column(linkify=True) + is_active = columns.BooleanColumn( + verbose_name=_('Is Active'), + ) + tags = columns.TagColumn(url_name='plugins:netbox_secrets:userkey_list') + actions = columns.ActionsColumn(actions=()) + + class Meta(NetBoxTable.Meta): + model = UserKey + fields = ( + 'pk', + 'user', + 'is_active', + 'created', + 'last_updated', + 'tags', + 'actions', + ) + default_columns = ( + 'pk', + 'id', + 'user', + 'is_active', + ) diff --git a/netbox_secrets/template_content.py b/netbox_secrets/template_content.py index 751e3ab..81a5551 100644 --- a/netbox_secrets/template_content.py +++ b/netbox_secrets/template_content.py @@ -3,10 +3,10 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.utils import OperationalError -from extras.plugins import PluginTemplateExtension + +from netbox.plugins import PluginTemplateExtension from netbox.views import generic from utilities.views import ViewTab, register_model_view - from .filtersets import SecretFilterSet from .models import Secret from .tables import SecretTable diff --git a/netbox_secrets/templates/netbox_secrets/activate_keys.html b/netbox_secrets/templates/netbox_secrets/activate_keys.html index 93c0215..bde4920 100644 --- a/netbox_secrets/templates/netbox_secrets/activate_keys.html +++ b/netbox_secrets/templates/netbox_secrets/activate_keys.html @@ -1,12 +1,50 @@ -{% extends "admin/base_site.html" %} +{% extends 'generic/_base.html' %} +{% load i18n %} + +{% block title %} + {% trans "Activate User Key" %} +{% endblock title %} + +{% block tabs %} + +{% endblock tabs %} {% block content %} +
-
- {% csrf_token %} - {{ form.as_p }} - - -
+
+ {% csrf_token %} + +
+ {% block form %} + {% include 'htmx/form.html' %} + {% endblock form %} +
-{% endblock %} +
+ {% block buttons %} + {% trans "Cancel" %} + + {% endblock buttons %} +
+
+
+{% endblock content %} diff --git a/netbox_secrets/templates/netbox_secrets/inc/view_tab.html b/netbox_secrets/templates/netbox_secrets/inc/view_tab.html deleted file mode 100644 index 6f6cf71..0000000 --- a/netbox_secrets/templates/netbox_secrets/inc/view_tab.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load render_table from django_tables2 %} - -{% block content %} - {% include 'inc/table_controls_htmx.html' with table_modal=table_config %} -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
-
-
-{% endblock content %} - -{% block modals %} - {{ block.super }} - {% table_config_form table %} -{% endblock modals %} diff --git a/netbox_secrets/templates/netbox_secrets/userkey.html b/netbox_secrets/templates/netbox_secrets/userkey.html index 080fd2f..d79b64d 100644 --- a/netbox_secrets/templates/netbox_secrets/userkey.html +++ b/netbox_secrets/templates/netbox_secrets/userkey.html @@ -23,13 +23,15 @@
{# Extra buttons #} {% block extra_controls %}{% endblock %} - {% if object %} {% if request.user|can_change:object %} - -  Edit - + {# Check if the object belongs to the currently logged-in user #} + {% if object.user == request.user %} + +  Edit + + {% endif %} {% endif %} {% endif %}
@@ -59,7 +61,7 @@
Overview
{% if not object.is_filled %} You don't have a user key on file. {% else %} - Your user key is inactive. Ask an administrator to enable it for you. + This user key is inactive. Ask an administrator to enable it for you. {% endif %} {% endif %} @@ -86,46 +88,48 @@
Overview
- {% if object.is_filled and object.is_active %} -
-
-
Session Key
-
- - - - - - - - - -
Status - {% if object.session_key %} - Active - {% else %} - Inactive - {% endif %} -
Created{{ object.session_key.created|placeholder }}
-
- {% endblock content %} diff --git a/netbox_secrets/templates/netbox_secrets/userkey_edit.html b/netbox_secrets/templates/netbox_secrets/userkey_edit.html index d39426d..47be3f1 100644 --- a/netbox_secrets/templates/netbox_secrets/userkey_edit.html +++ b/netbox_secrets/templates/netbox_secrets/userkey_edit.html @@ -1,95 +1,121 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} +{% extends 'generic/_base.html' %} +{% load buttons %} +{% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% load static %} +{% load i18n %} -{% block content-wrapper %} -
-
- {# Link to model documentation #} - {% if settings.DOCS_ROOT and object.docs_url %} -
- - Help - + +{% block title %} + {% if object.pk %} + {% trans "Editing" %} {{ object|meta:"verbose_name" }} {{ object }} + {% else %} + {% blocktrans trimmed with object_type=object|meta:"verbose_name" %} + Add a new {{ object_type }} + {% endblocktrans %} + {% endif %} +{% endblock title %} + +{% block tabs %} + +{% endblock tabs %} + + +{% block content %} +
+ +
+ {% csrf_token %} + {% if object.is_active %} + {% endif %} - - {% csrf_token %} +
{% block form %} - {% if object.is_active %} - - {% endif %} -
- {% render_field form.public_key %} -
- {% endblock %} + {% include 'htmx/form.html' %} + {% endblock form %} +
-
- {% block buttons %} - - {% if object.pk %} - - {% else %} - - {% endif %} - Cancel - {% endblock %} -
-
-