netbox-certificates-plugin 0.4.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. netbox_certificates/__init__.py +21 -0
  2. netbox_certificates/api/__init__.py +3 -0
  3. netbox_certificates/api/serializers.py +385 -0
  4. netbox_certificates/api/urls.py +16 -0
  5. netbox_certificates/api/views.py +327 -0
  6. netbox_certificates/choices.py +124 -0
  7. netbox_certificates/constants.py +21 -0
  8. netbox_certificates/dashboard.py +23 -0
  9. netbox_certificates/filtersets.py +295 -0
  10. netbox_certificates/forms.py +780 -0
  11. netbox_certificates/jobs.py +20 -0
  12. netbox_certificates/management/__init__.py +0 -0
  13. netbox_certificates/management/commands/__init__.py +0 -0
  14. netbox_certificates/management/commands/reconcile_certificate_links.py +10 -0
  15. netbox_certificates/management/commands/refresh_certificate_status.py +10 -0
  16. netbox_certificates/migrations/0001_initial.py +149 -0
  17. netbox_certificates/migrations/0002_alter_bundle_options_alter_privatekey_options.py +9 -0
  18. netbox_certificates/migrations/0003_remove_privatekey_curve_bundle_identity_fingerprint_and_more.py +14 -0
  19. netbox_certificates/migrations/0004_remove_bundle_protection_password_hash_and_more.py +11 -0
  20. netbox_certificates/migrations/0005_certificate_supersedes_and_permissions.py +13 -0
  21. netbox_certificates/migrations/0006_expiry_alerts.py +73 -0
  22. netbox_certificates/migrations/0007_trigger_field_names.py +14 -0
  23. netbox_certificates/migrations/0008_expiry_alert_webhook_tls_verification.py +12 -0
  24. netbox_certificates/migrations/0009_expiry_alert_expired_certificate_preference.py +12 -0
  25. netbox_certificates/migrations/0010_groups_and_repeat_alerts.py +67 -0
  26. netbox_certificates/migrations/0011_object_groups_and_certificate_authorities.py +99 -0
  27. netbox_certificates/migrations/0012_group_hierarchy_and_ui_labels.py +107 -0
  28. netbox_certificates/migrations/0013_root_authorities_and_bundle_status.py +107 -0
  29. netbox_certificates/migrations/__init__.py +0 -0
  30. netbox_certificates/models.py +407 -0
  31. netbox_certificates/navigation.py +70 -0
  32. netbox_certificates/permissions.py +44 -0
  33. netbox_certificates/search.py +44 -0
  34. netbox_certificates/services/__init__.py +1 -0
  35. netbox_certificates/services/alerts.py +416 -0
  36. netbox_certificates/services/bundles.py +253 -0
  37. netbox_certificates/services/certificate_authorities.py +95 -0
  38. netbox_certificates/services/chain.py +78 -0
  39. netbox_certificates/services/csr.py +138 -0
  40. netbox_certificates/services/duplicates.py +51 -0
  41. netbox_certificates/services/encryption.py +48 -0
  42. netbox_certificates/services/expiry.py +60 -0
  43. netbox_certificates/services/importing.py +136 -0
  44. netbox_certificates/services/ingest.py +66 -0
  45. netbox_certificates/services/inventory.py +96 -0
  46. netbox_certificates/services/linker.py +232 -0
  47. netbox_certificates/services/parser.py +272 -0
  48. netbox_certificates/services/pkcs12_export.py +34 -0
  49. netbox_certificates/services/renewal.py +19 -0
  50. netbox_certificates/services/status.py +29 -0
  51. netbox_certificates/services/unified_import.py +147 -0
  52. netbox_certificates/signals.py +42 -0
  53. netbox_certificates/static/netbox_certificates/inventory.css +164 -0
  54. netbox_certificates/tables.py +233 -0
  55. netbox_certificates/templates/netbox_certificates/_groups.html +13 -0
  56. netbox_certificates/templates/netbox_certificates/_links.html +20 -0
  57. netbox_certificates/templates/netbox_certificates/bundle.html +3 -0
  58. netbox_certificates/templates/netbox_certificates/bundle_export.html +1 -0
  59. netbox_certificates/templates/netbox_certificates/bundle_list.html +4 -0
  60. netbox_certificates/templates/netbox_certificates/certificate.html +28 -0
  61. netbox_certificates/templates/netbox_certificates/certificate_authority.html +38 -0
  62. netbox_certificates/templates/netbox_certificates/certificate_authority_list.html +1 -0
  63. netbox_certificates/templates/netbox_certificates/certificate_list.html +4 -0
  64. netbox_certificates/templates/netbox_certificates/csr.html +3 -0
  65. netbox_certificates/templates/netbox_certificates/csr_generate.html +241 -0
  66. netbox_certificates/templates/netbox_certificates/csr_list.html +5 -0
  67. netbox_certificates/templates/netbox_certificates/dashboard_widget.html +1 -0
  68. netbox_certificates/templates/netbox_certificates/expiration_alert_email.html +87 -0
  69. netbox_certificates/templates/netbox_certificates/expiration_alerts.html +136 -0
  70. netbox_certificates/templates/netbox_certificates/expiration_dashboard.html +1 -0
  71. netbox_certificates/templates/netbox_certificates/group.html +47 -0
  72. netbox_certificates/templates/netbox_certificates/group_list.html +1 -0
  73. netbox_certificates/templates/netbox_certificates/import_objects.html +4 -0
  74. netbox_certificates/templates/netbox_certificates/inventory.html +90 -0
  75. netbox_certificates/templates/netbox_certificates/link_add.html +1 -0
  76. netbox_certificates/templates/netbox_certificates/link_remove.html +1 -0
  77. netbox_certificates/templates/netbox_certificates/privatekey.html +3 -0
  78. netbox_certificates/templates/netbox_certificates/privatekey_list.html +4 -0
  79. netbox_certificates/urls.py +70 -0
  80. netbox_certificates/views.py +755 -0
  81. netbox_certificates_plugin-0.4.11.dist-info/METADATA +440 -0
  82. netbox_certificates_plugin-0.4.11.dist-info/RECORD +86 -0
  83. netbox_certificates_plugin-0.4.11.dist-info/WHEEL +5 -0
  84. netbox_certificates_plugin-0.4.11.dist-info/licenses/LICENSE +201 -0
  85. netbox_certificates_plugin-0.4.11.dist-info/licenses/NOTICE +9 -0
  86. netbox_certificates_plugin-0.4.11.dist-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ from netbox.plugins import PluginConfig
2
+
3
+
4
+ class NetBoxCertificatesConfig(PluginConfig):
5
+ name = "netbox_certificates"
6
+ verbose_name = "NetBox Certificates Plugin"
7
+ description = "SSL certificate, private key, CSR, bundle, Groups, CA identities, and expiration management"
8
+ version = "0.4.11"
9
+ base_url = "ssl-certificates"
10
+ min_version = "4.5.9"
11
+ max_version = "4.5.10"
12
+ required_settings = ["encryption_key"]
13
+
14
+ def ready(self):
15
+ super().ready()
16
+ from . import signals # noqa: F401
17
+ from . import dashboard # noqa: F401
18
+ from . import jobs # noqa: F401
19
+
20
+
21
+ config = NetBoxCertificatesConfig
@@ -0,0 +1,3 @@
1
+ from .serializers import ArtifactGroupSerializer, BundleSerializer, CertificateSerializer, CSRSerializer, PrivateKeySerializer
2
+
3
+ __all__ = ("ArtifactGroupSerializer", "BundleSerializer", "CertificateSerializer", "CSRSerializer", "PrivateKeySerializer")
@@ -0,0 +1,385 @@
1
+ from django.db import transaction
2
+ from rest_framework import serializers
3
+ from rest_framework.exceptions import PermissionDenied
4
+ from netbox.api.serializers import NetBoxModelSerializer
5
+
6
+ from netbox_certificates.choices import LinkOriginChoices
7
+ from netbox_certificates.models import ArtifactGroup, ArtifactLink, Bundle, Certificate, CertificateAuthority, CSR, ExpiryAlertConfiguration, ExpiryAlertEvent, PrivateKey
8
+ from netbox_certificates.permissions import object_allowed
9
+ from netbox_certificates.services.duplicates import find_duplicate
10
+ from netbox_certificates.services.encryption import encrypt_private_key, encrypt_secret
11
+ from netbox_certificates.services.expiry import remaining_days
12
+ from netbox_certificates.services.ingest import after_artifact_save
13
+ from netbox_certificates.services.parser import ArtifactParseError, parse_blob
14
+
15
+
16
+ def _require_superuser_write_token(request, purpose):
17
+ auth = getattr(request, "auth", None) if request is not None else None
18
+ user = getattr(request, "user", None) if request is not None else None
19
+ if not getattr(user, "is_superuser", False):
20
+ raise PermissionDenied(f"{purpose} requires a NetBox superuser account.")
21
+ if auth is None or not hasattr(auth, "write_enabled"):
22
+ raise PermissionDenied(f"{purpose} requires NetBox API token authentication.")
23
+ if not auth.write_enabled:
24
+ raise PermissionDenied(f"{purpose} requires a write-enabled NetBox API token.")
25
+
26
+
27
+ def _single(parsed, kind):
28
+ matches = [p for p in parsed if p.kind == kind]
29
+ if len(matches) != 1 or len(parsed) != 1:
30
+ raise serializers.ValidationError(f"Expected exactly one {kind.replace('_', ' ')} object.")
31
+ return matches[0]
32
+
33
+
34
+ def _group_details(obj, request=None):
35
+ groups = obj.groups.all().order_by("name")
36
+ user = getattr(request, "user", None) if request is not None else None
37
+ if user is not None and hasattr(ArtifactGroup.objects, "restrict"):
38
+ visible_ids = ArtifactGroup.objects.restrict(user, "view").filter(pk__in=groups.values("pk")).values_list("pk", flat=True)
39
+ groups = groups.filter(pk__in=visible_ids)
40
+ return [{"id": group.pk, "name": group.name, "url": group.get_absolute_url()} for group in groups]
41
+
42
+
43
+ class ArtifactGroupSerializer(NetBoxModelSerializer):
44
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:artifactgroup-detail")
45
+ parent = serializers.PrimaryKeyRelatedField(queryset=ArtifactGroup.objects.all(), required=False, allow_null=True)
46
+ children = serializers.SerializerMethodField()
47
+ members = serializers.SerializerMethodField()
48
+ certificates = serializers.PrimaryKeyRelatedField(many=True, queryset=Certificate.objects.all(), required=False)
49
+ private_keys = serializers.PrimaryKeyRelatedField(many=True, queryset=PrivateKey.objects.all(), required=False)
50
+ csrs = serializers.PrimaryKeyRelatedField(many=True, queryset=CSR.objects.all(), required=False)
51
+ bundles = serializers.PrimaryKeyRelatedField(many=True, queryset=Bundle.objects.all(), required=False)
52
+
53
+ class Meta:
54
+ model = ArtifactGroup
55
+ fields = (
56
+ "id", "url", "display", "name", "parent", "children", "members", "owner", "description", "comments",
57
+ "certificates", "private_keys", "csrs", "bundles", "tags", "custom_fields", "created", "last_updated",
58
+ )
59
+ brief_fields = ("id", "url", "display", "name", "parent")
60
+
61
+ def validate(self, attrs):
62
+ attrs = super().validate(attrs)
63
+ parent = attrs.get("parent", getattr(self.instance, "parent", None))
64
+ if self.instance is not None and parent is not None:
65
+ if parent.pk == self.instance.pk or parent.pk in set(self.instance.descendant_ids()):
66
+ raise serializers.ValidationError({"parent": "A Group cannot be nested below itself or one of its descendants."})
67
+ request = self.context.get("request")
68
+ if request is not None and parent is not None and not object_allowed(request.user, parent, "view"):
69
+ raise PermissionDenied("Selecting this parent Group requires view permission on it.")
70
+ return attrs
71
+
72
+ @staticmethod
73
+ def _memberships(validated_data):
74
+ return {name: validated_data.pop(name, None) for name in ("certificates", "private_keys", "csrs", "bundles")}
75
+
76
+ def _validate_members(self, memberships):
77
+ request = self.context.get("request")
78
+ if request is None:
79
+ return
80
+ for objects in memberships.values():
81
+ if objects is None:
82
+ continue
83
+ for obj in objects:
84
+ if not object_allowed(request.user, obj, "change"):
85
+ raise PermissionDenied(f"Changing Group membership for {obj} requires change permission on that object.")
86
+
87
+ @staticmethod
88
+ def _apply_memberships(group, memberships):
89
+ for name, objects in memberships.items():
90
+ if objects is not None:
91
+ getattr(group, name).set(objects)
92
+
93
+ @staticmethod
94
+ def _member_item(kind, obj):
95
+ return {"type": kind, "id": obj.pk, "name": str(obj), "url": obj.get_absolute_url()}
96
+
97
+ def _visible(self, obj):
98
+ request = self.context.get("request")
99
+ return request is None or object_allowed(request.user, obj, "view")
100
+
101
+ def get_children(self, obj):
102
+ return [
103
+ self._member_item("group", item)
104
+ for item in obj.children.all().order_by("name")
105
+ if self._visible(item)
106
+ ]
107
+
108
+ def get_members(self, obj):
109
+ result = []
110
+ for kind, queryset in (
111
+ ("group", obj.children.all().order_by("name")),
112
+ ("bundle", obj.bundles.all().order_by("name")),
113
+ ("certificate", obj.certificates.all().order_by("name")),
114
+ ("private_key", obj.private_keys.all().order_by("name")),
115
+ ("csr", obj.csrs.all().order_by("name")),
116
+ ):
117
+ result.extend(self._member_item(kind, item) for item in queryset if self._visible(item))
118
+ return result
119
+
120
+ def create(self, validated_data):
121
+ memberships = self._memberships(validated_data)
122
+ self._validate_members(memberships)
123
+ with transaction.atomic():
124
+ group = super().create(validated_data)
125
+ self._apply_memberships(group, memberships)
126
+ return group
127
+
128
+ def update(self, instance, validated_data):
129
+ memberships = self._memberships(validated_data)
130
+ self._validate_members(memberships)
131
+ with transaction.atomic():
132
+ group = super().update(instance, validated_data)
133
+ self._apply_memberships(group, memberships)
134
+ return group
135
+
136
+
137
+ class CertificateAuthoritySerializer(NetBoxModelSerializer):
138
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:certificateauthority-detail")
139
+ certificate_count = serializers.SerializerMethodField()
140
+
141
+ class Meta:
142
+ model = CertificateAuthority
143
+ fields = ("id", "url", "display", "name", "certificate_count", "description", "created", "last_updated")
144
+ brief_fields = ("id", "url", "display", "name", "certificate_count")
145
+ read_only_fields = fields
146
+
147
+ def get_certificate_count(self, obj):
148
+ request = self.context.get("request")
149
+ if request is None:
150
+ return obj.certificates.count()
151
+ return Certificate.objects.restrict(request.user, "view").filter(authority=obj).count()
152
+
153
+
154
+ class CertificateSerializer(NetBoxModelSerializer):
155
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:certificate-detail")
156
+ material = serializers.CharField(required=False, allow_blank=False)
157
+ remaining_days = serializers.SerializerMethodField()
158
+ authority = serializers.PrimaryKeyRelatedField(read_only=True)
159
+ authority_details = serializers.SerializerMethodField()
160
+ groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
161
+ group_details = serializers.SerializerMethodField()
162
+
163
+ class Meta:
164
+ model = Certificate
165
+ fields = (
166
+ "id", "url", "display", "name", "status", "source_filename", "source_format", "material",
167
+ "fingerprint_sha256", "public_key_fingerprint", "serial_number", "subject", "issuer", "authority", "authority_details",
168
+ "subject_alternative_names", "valid_from", "valid_to", "signature_algorithm", "key_type",
169
+ "key_size", "curve", "is_ca", "parent_certificate", "supersedes", "remaining_days",
170
+ "alert_trigger", "trigger_unit", "owner", "groups", "group_details", "description", "comments",
171
+ "tags", "custom_fields", "created", "last_updated",
172
+ )
173
+ brief_fields = ("id", "url", "display", "name", "status", "valid_to", "remaining_days", "groups")
174
+ read_only_fields = (
175
+ "status", "source_format", "fingerprint_sha256", "public_key_fingerprint", "serial_number", "subject",
176
+ "issuer", "authority", "authority_details", "subject_alternative_names", "valid_from", "valid_to", "signature_algorithm", "key_type",
177
+ "key_size", "curve", "is_ca", "parent_certificate", "remaining_days", "group_details",
178
+ )
179
+ def get_remaining_days(self, obj): return remaining_days(obj)
180
+ def get_authority_details(self, obj):
181
+ if obj.authority is None:
182
+ return None
183
+ return {"id": obj.authority.pk, "name": obj.authority.name, "url": obj.authority.get_absolute_url()}
184
+ def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
185
+ def validate(self, attrs):
186
+ material = attrs.pop("material", None)
187
+ if self.instance is None and not material:
188
+ raise serializers.ValidationError({"material": "Certificate material is required."})
189
+ if material:
190
+ try: parsed = _single(parse_blob(material.encode(), filename=attrs.get("source_filename") or "certificate.pem"), "certificate")
191
+ except ArtifactParseError as exc: raise serializers.ValidationError({"material": str(exc)}) from exc
192
+ duplicate = find_duplicate("certificate", parsed.metadata, exclude_pk=self.instance.pk if self.instance else None)
193
+ if duplicate: raise serializers.ValidationError({"material": duplicate.message()})
194
+ self._parsed = parsed
195
+ attrs = self._prepare(attrs)
196
+ return super().validate(attrs)
197
+ def _prepare(self, data):
198
+ parsed = getattr(self, "_parsed", None)
199
+ if parsed:
200
+ data["material"] = parsed.data.decode("ascii"); data.update(parsed.metadata); data["source_format"] = parsed.source_format; data.setdefault("name", parsed.name)
201
+ return data
202
+ def create(self, validated_data):
203
+ with transaction.atomic():
204
+ obj = super().create(self._prepare(validated_data)); after_artifact_save(obj); return obj
205
+ def update(self, instance, validated_data):
206
+ with transaction.atomic():
207
+ obj = super().update(instance, self._prepare(validated_data)); after_artifact_save(obj); return obj
208
+
209
+
210
+ class PrivateKeySerializer(NetBoxModelSerializer):
211
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:privatekey-detail")
212
+ key_material = serializers.CharField(write_only=True, required=False, allow_blank=False)
213
+ input_password = serializers.CharField(write_only=True, required=False, allow_blank=True)
214
+ groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
215
+ group_details = serializers.SerializerMethodField()
216
+ class Meta:
217
+ model = PrivateKey
218
+ fields = ("id", "url", "display", "name", "source_filename", "source_format", "key_material", "input_password", "material_sha256", "public_key_fingerprint", "key_type", "key_size", "encrypted_on_import", "owner", "groups", "group_details", "description", "comments", "tags", "custom_fields", "created", "last_updated")
219
+ brief_fields = ("id", "url", "display", "name", "key_type", "public_key_fingerprint", "groups")
220
+ read_only_fields = ("source_format", "material_sha256", "public_key_fingerprint", "key_type", "key_size", "encrypted_on_import", "group_details")
221
+ def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
222
+ def validate(self, attrs):
223
+ material = attrs.pop("key_material", None); password = attrs.pop("input_password", None) or None; attrs = super().validate(attrs)
224
+ if self.instance is None and not material: raise serializers.ValidationError({"key_material": "Private key material is required."})
225
+ if material:
226
+ _require_superuser_write_token(self.context.get("request"), "Private-key material API access")
227
+ try: parsed = _single(parse_blob(material.encode(), password=password, filename=attrs.get("source_filename") or "private.key"), "private_key")
228
+ except ArtifactParseError as exc: raise serializers.ValidationError({"key_material": str(exc)}) from exc
229
+ duplicate = find_duplicate("private_key", parsed.metadata, exclude_pk=self.instance.pk if self.instance else None)
230
+ if duplicate: raise serializers.ValidationError({"key_material": duplicate.message()})
231
+ self._parsed = parsed
232
+ return attrs
233
+ def _prepare(self, data):
234
+ parsed = getattr(self, "_parsed", None)
235
+ if parsed:
236
+ data["encrypted_material"] = encrypt_private_key(parsed.data); metadata = parsed.metadata.copy(); metadata.pop("curve", None); data.update(metadata); data["source_format"] = parsed.source_format; data.setdefault("name", parsed.name)
237
+ return data
238
+ def create(self, validated_data):
239
+ with transaction.atomic(): obj = super().create(self._prepare(validated_data)); after_artifact_save(obj); return obj
240
+ def update(self, instance, validated_data):
241
+ with transaction.atomic(): obj = super().update(instance, self._prepare(validated_data)); after_artifact_save(obj); return obj
242
+
243
+
244
+ class CSRSerializer(NetBoxModelSerializer):
245
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:csr-detail")
246
+ material = serializers.CharField(required=False, allow_blank=False)
247
+ groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
248
+ group_details = serializers.SerializerMethodField()
249
+ class Meta:
250
+ model = CSR
251
+ fields = ("id", "url", "display", "name", "source_filename", "source_format", "material", "fingerprint_sha256", "public_key_fingerprint", "subject", "subject_alternative_names", "signature_algorithm", "key_type", "key_size", "curve", "owner", "groups", "group_details", "description", "comments", "tags", "custom_fields", "created", "last_updated")
252
+ brief_fields = ("id", "url", "display", "name", "subject", "public_key_fingerprint", "groups")
253
+ read_only_fields = ("source_format", "fingerprint_sha256", "public_key_fingerprint", "subject", "subject_alternative_names", "signature_algorithm", "key_type", "key_size", "curve", "group_details")
254
+ def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
255
+ def validate(self, attrs):
256
+ material = attrs.pop("material", None)
257
+ if self.instance is None and not material: raise serializers.ValidationError({"material": "CSR material is required."})
258
+ if material:
259
+ try: parsed = _single(parse_blob(material.encode(), filename=attrs.get("source_filename") or "request.csr"), "csr")
260
+ except ArtifactParseError as exc: raise serializers.ValidationError({"material": str(exc)}) from exc
261
+ duplicate = find_duplicate("csr", parsed.metadata, exclude_pk=self.instance.pk if self.instance else None)
262
+ if duplicate: raise serializers.ValidationError({"material": duplicate.message()})
263
+ self._parsed = parsed
264
+ attrs = self._prepare(attrs)
265
+ return super().validate(attrs)
266
+ def _prepare(self, data):
267
+ parsed = getattr(self, "_parsed", None)
268
+ if parsed: data["material"] = parsed.data.decode("ascii"); data.update(parsed.metadata); data["source_format"] = parsed.source_format; data.setdefault("name", parsed.name)
269
+ return data
270
+ def create(self, validated_data):
271
+ with transaction.atomic(): obj = super().create(self._prepare(validated_data)); after_artifact_save(obj); return obj
272
+ def update(self, instance, validated_data):
273
+ with transaction.atomic(): obj = super().update(instance, self._prepare(validated_data)); after_artifact_save(obj); return obj
274
+
275
+
276
+ class BundleSerializer(NetBoxModelSerializer):
277
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:bundle-detail")
278
+ certificate = serializers.SerializerMethodField(); private_key = serializers.SerializerMethodField(); csr = serializers.SerializerMethodField(); chain_certificates = serializers.SerializerMethodField()
279
+ groups = serializers.PrimaryKeyRelatedField(many=True, queryset=ArtifactGroup.objects.all(), required=False)
280
+ group_details = serializers.SerializerMethodField()
281
+ class Meta:
282
+ model = Bundle
283
+ fields = ("id", "url", "display", "name", "identity_fingerprint", "source_filename", "archive_format", "status", "certificate", "private_key", "csr", "chain_certificates", "owner", "groups", "group_details", "description", "comments", "tags", "custom_fields", "created", "last_updated")
284
+ brief_fields = ("id", "url", "display", "name", "status", "groups")
285
+ read_only_fields = ("name", "identity_fingerprint", "source_filename", "archive_format", "status", "certificate", "private_key", "csr", "chain_certificates", "group_details")
286
+ def _visible(self, obj):
287
+ if obj is None: return None
288
+ request = self.context.get("request")
289
+ if request is None: return obj
290
+ return obj if obj.__class__.objects.restrict(request.user, "view").filter(pk=obj.pk).exists() else None
291
+ def get_certificate(self, obj):
292
+ value = self._visible(obj.certificate); return CertificateSerializer(value, context=self.context).data if value else None
293
+ def get_private_key(self, obj):
294
+ value = self._visible(obj.private_key); return PrivateKeySerializer(value, context=self.context).data if value else None
295
+ def get_csr(self, obj):
296
+ value = self._visible(obj.csr); return CSRSerializer(value, context=self.context).data if value else None
297
+ def get_chain_certificates(self, obj):
298
+ request = self.context.get("request"); qs = obj.chain_certificates.all(); qs = qs.restrict(request.user, "view") if request is not None else qs
299
+ return CertificateSerializer(qs, many=True, context=self.context).data
300
+ def get_group_details(self, obj): return _group_details(obj, self.context.get("request"))
301
+
302
+
303
+ class ArtifactLinkSerializer(NetBoxModelSerializer):
304
+ display = serializers.SerializerMethodField()
305
+ class Meta:
306
+ model = ArtifactLink
307
+ fields = ("id", "display", "source_type", "source_id", "target_type", "target_id", "relation", "origin", "active", "note", "created", "last_updated")
308
+ read_only_fields = ("origin", "created", "last_updated")
309
+ def get_display(self, obj): return f"Artifact link #{obj.pk}"
310
+ def _resolve_visible_endpoint(self, object_type, object_id, field):
311
+ model = object_type.model_class() if object_type else None; manager = getattr(model, "objects", None) if model else None; request = self.context.get("request")
312
+ if model is None or manager is None: raise serializers.ValidationError({field: "The selected object type cannot be linked."})
313
+ qs = manager.all(); qs = qs.restrict(request.user, "view") if request is not None and hasattr(qs, "restrict") else qs
314
+ try: return qs.get(pk=object_id)
315
+ except model.DoesNotExist: raise serializers.ValidationError({field: "The selected object does not exist or is not visible."})
316
+ def validate(self, attrs):
317
+ attrs = super().validate(attrs)
318
+ source_type = attrs.get("source_type", getattr(self.instance, "source_type", None)); source_id = attrs.get("source_id", getattr(self.instance, "source_id", None)); target_type = attrs.get("target_type", getattr(self.instance, "target_type", None)); target_id = attrs.get("target_id", getattr(self.instance, "target_id", None))
319
+ source = self._resolve_visible_endpoint(source_type, source_id, "source_id"); target = self._resolve_visible_endpoint(target_type, target_id, "target_id")
320
+ if not isinstance(source, (Certificate, PrivateKey, CSR, Bundle)) and not isinstance(target, (Certificate, PrivateKey, CSR, Bundle)):
321
+ raise serializers.ValidationError("At least one endpoint must be a NetBox Certificates object.")
322
+ if source_type.pk == target_type.pk and source_id == target_id: raise serializers.ValidationError("An object cannot be linked to itself.")
323
+ return attrs
324
+ def create(self, validated_data): validated_data["origin"] = LinkOriginChoices.MANUAL; return super().create(validated_data)
325
+ def update(self, instance, validated_data):
326
+ if instance.origin != LinkOriginChoices.MANUAL: raise serializers.ValidationError("Automatically generated links cannot be modified through the API.")
327
+ validated_data["origin"] = LinkOriginChoices.MANUAL; return super().update(instance, validated_data)
328
+
329
+
330
+ class ExpiryAlertConfigurationSerializer(NetBoxModelSerializer):
331
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:expiration-alert-configuration-detail")
332
+ smtp_password = serializers.CharField(write_only=True, required=False, allow_blank=True, trim_whitespace=False)
333
+ webhook_url = serializers.URLField(write_only=True, required=False, allow_blank=True)
334
+ webhook_bearer_token = serializers.CharField(write_only=True, required=False, allow_blank=True, trim_whitespace=False)
335
+ smtp_password_configured = serializers.SerializerMethodField(); webhook_url_configured = serializers.SerializerMethodField(); webhook_bearer_token_configured = serializers.SerializerMethodField()
336
+ class Meta:
337
+ model = ExpiryAlertConfiguration
338
+ fields = (
339
+ "id", "url", "display", "check_interval_minutes", "alert_on_expired_certificates", "alert_repeat_mode",
340
+ "email_enabled", "smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_password_configured", "smtp_use_tls", "smtp_use_ssl", "email_from_address", "email_recipients", "include_superusers",
341
+ "webhook_enabled", "webhook_url", "webhook_url_configured", "webhook_bearer_token", "webhook_bearer_token_configured", "webhook_allow_http", "webhook_ignore_tls_verification",
342
+ "last_check_at", "last_check_success", "last_check_message", "email_last_test_at", "email_last_test_success", "email_last_test_message", "webhook_last_test_at", "webhook_last_test_success", "webhook_last_test_message", "custom_fields", "created", "last_updated",
343
+ )
344
+ brief_fields = ("id", "url", "display", "alert_on_expired_certificates", "alert_repeat_mode", "email_enabled", "webhook_enabled", "last_check_at", "last_check_success")
345
+ read_only_fields = ("last_check_at", "last_check_success", "last_check_message", "email_last_test_at", "email_last_test_success", "email_last_test_message", "webhook_last_test_at", "webhook_last_test_success", "webhook_last_test_message")
346
+ def get_smtp_password_configured(self, obj): return bool(obj.smtp_password_encrypted)
347
+ def get_webhook_url_configured(self, obj): return bool(obj.webhook_url_encrypted)
348
+ def get_webhook_bearer_token_configured(self, obj): return bool(obj.webhook_bearer_token_encrypted)
349
+ def validate(self, attrs):
350
+ smtp_password = attrs.pop("smtp_password", None); webhook_url = attrs.pop("webhook_url", None); webhook_token = attrs.pop("webhook_bearer_token", None)
351
+ if smtp_password or webhook_token: _require_superuser_write_token(self.context.get("request"), "Alert secret API access")
352
+ attrs = super().validate(attrs)
353
+ tls = attrs.get("smtp_use_tls", getattr(self.instance, "smtp_use_tls", True)); ssl = attrs.get("smtp_use_ssl", getattr(self.instance, "smtp_use_ssl", False))
354
+ if tls and ssl: raise serializers.ValidationError({"smtp_use_ssl": "TLS and implicit SSL cannot both be enabled."})
355
+ email_enabled = attrs.get("email_enabled", getattr(self.instance, "email_enabled", False))
356
+ if email_enabled:
357
+ host = attrs.get("smtp_host", getattr(self.instance, "smtp_host", "")); port = attrs.get("smtp_port", getattr(self.instance, "smtp_port", None)); sender = attrs.get("email_from_address", getattr(self.instance, "email_from_address", "")); include_superusers = attrs.get("include_superusers", getattr(self.instance, "include_superusers", True)); recipients = attrs.get("email_recipients", getattr(self.instance, "email_recipients", "")); errors = {}
358
+ if not host: errors["smtp_host"] = "SMTP host is required when email alerts are enabled."
359
+ if not port: errors["smtp_port"] = "SMTP port is required when email alerts are enabled."
360
+ if not sender: errors["email_from_address"] = "From address is required when email alerts are enabled."
361
+ if not include_superusers and not str(recipients).strip(): errors["email_recipients"] = "Configure a recipient or enable NetBox superuser recipients."
362
+ if errors: raise serializers.ValidationError(errors)
363
+ webhook_enabled = attrs.get("webhook_enabled", getattr(self.instance, "webhook_enabled", False)); existing_webhook = bool(getattr(self.instance, "webhook_url_encrypted", None)); allow_http = attrs.get("webhook_allow_http", getattr(self.instance, "webhook_allow_http", False))
364
+ if webhook_enabled and not webhook_url and not existing_webhook: raise serializers.ValidationError({"webhook_url": "Webhook URL is required when webhook alerts are enabled."})
365
+ if webhook_url and webhook_url.lower().startswith("http://") and not allow_http: raise serializers.ValidationError({"webhook_url": "HTTP webhooks require webhook_allow_http=true."})
366
+ self._smtp_password, self._webhook_url, self._webhook_token = smtp_password, webhook_url, webhook_token
367
+ return attrs
368
+ def _apply_secrets(self, data):
369
+ if getattr(self, "_smtp_password", None): data["smtp_password_encrypted"] = encrypt_secret(self._smtp_password)
370
+ if getattr(self, "_webhook_url", None): data["webhook_url_encrypted"] = encrypt_secret(self._webhook_url)
371
+ if getattr(self, "_webhook_token", None): data["webhook_bearer_token_encrypted"] = encrypt_secret(self._webhook_token)
372
+ return data
373
+ def create(self, validated_data):
374
+ if ExpiryAlertConfiguration.objects.exists(): raise serializers.ValidationError("Only one expiration alert configuration can exist.")
375
+ return super().create(self._apply_secrets(validated_data))
376
+ def update(self, instance, validated_data): return super().update(instance, self._apply_secrets(validated_data))
377
+
378
+
379
+ class ExpiryAlertEventSerializer(NetBoxModelSerializer):
380
+ url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_certificates-api:expiration-alert-event-detail")
381
+ class Meta:
382
+ model = ExpiryAlertEvent
383
+ fields = ("id", "url", "display", "certificate", "method", "certificate_valid_to", "trigger_unit", "alert_trigger", "trigger_at", "last_attempt_at", "delivered_at", "success", "attempt_count", "status_code", "message", "custom_fields", "created", "last_updated")
384
+ brief_fields = ("id", "url", "display", "certificate", "method", "success", "last_attempt_at", "attempt_count")
385
+ read_only_fields = fields
@@ -0,0 +1,16 @@
1
+ from django.urls import path
2
+ from netbox.api.routers import NetBoxRouter
3
+ from .views import ArtifactGroupViewSet, ArtifactLinkViewSet, BundleViewSet, CertificateAuthorityViewSet, CertificateViewSet, CSRViewSet, ExpiryAlertConfigurationViewSet, ExpiryAlertEventViewSet, PrivateKeyViewSet, UnifiedImportAPIView
4
+
5
+ app_name = "netbox_certificates"
6
+ router = NetBoxRouter()
7
+ router.register("certificates", CertificateViewSet)
8
+ router.register("certificate-authorities", CertificateAuthorityViewSet)
9
+ router.register("private-keys", PrivateKeyViewSet)
10
+ router.register("csrs", CSRViewSet)
11
+ router.register("bundles", BundleViewSet)
12
+ router.register("groups", ArtifactGroupViewSet)
13
+ router.register("artifact-links", ArtifactLinkViewSet, basename="artifact-link")
14
+ router.register("expiration-alert-configurations", ExpiryAlertConfigurationViewSet, basename="expiration-alert-configuration")
15
+ router.register("expiration-alert-events", ExpiryAlertEventViewSet, basename="expiration-alert-event")
16
+ urlpatterns = router.urls + [path("import-objects/", UnifiedImportAPIView.as_view(), name="import-objects")]