netbox-passwork 1.3.1__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.
- netbox_passwork/__init__.py +13 -0
- netbox_passwork/admin.py +71 -0
- netbox_passwork/api/__init__.py +0 -0
- netbox_passwork/api/serializers.py +11 -0
- netbox_passwork/config.py +29 -0
- netbox_passwork/exceptions.py +54 -0
- netbox_passwork/gateway.py +156 -0
- netbox_passwork/migrations/0001_initial.py +141 -0
- netbox_passwork/migrations/0002_alter_ids.py +25 -0
- netbox_passwork/migrations/0003_changelog.py +80 -0
- netbox_passwork/migrations/__init__.py +0 -0
- netbox_passwork/models.py +111 -0
- netbox_passwork/passwork_client.py +263 -0
- netbox_passwork/permissions.py +68 -0
- netbox_passwork/serializers.py +54 -0
- netbox_passwork/static/netbox_passwork/passwork.js +845 -0
- netbox_passwork/static/netbox_passwork/styles.css +0 -0
- netbox_passwork/template_extensions.py +59 -0
- netbox_passwork/templates/netbox_passwork/device_passwork.html +6 -0
- netbox_passwork/templates/netbox_passwork/login_modal.html +30 -0
- netbox_passwork/templates/netbox_passwork/picker_modal.html +47 -0
- netbox_passwork/templates/netbox_passwork/secret_detail.html +0 -0
- netbox_passwork/templates/netbox_passwork/secret_row.html +2 -0
- netbox_passwork/templates/netbox_passwork/secrets_tab.html +59 -0
- netbox_passwork/templates/netbox_passwork/service_passwork.html +6 -0
- netbox_passwork/templates/netbox_passwork/totp_modal.html +36 -0
- netbox_passwork/templates/netbox_passwork/vm_passwork.html +6 -0
- netbox_passwork/urls.py +21 -0
- netbox_passwork/utils.py +9 -0
- netbox_passwork/views.py +426 -0
- netbox_passwork-1.3.1.dist-info/METADATA +271 -0
- netbox_passwork-1.3.1.dist-info/RECORD +35 -0
- netbox_passwork-1.3.1.dist-info/WHEEL +5 -0
- netbox_passwork-1.3.1.dist-info/licenses/LICENSE +202 -0
- netbox_passwork-1.3.1.dist-info/top_level.txt +1 -0
netbox_passwork/admin.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from netbox_passwork.models import PassworkAuditLog, PassworkBinding
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(PassworkBinding)
|
|
7
|
+
class PassworkBindingAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = [
|
|
9
|
+
"id",
|
|
10
|
+
"object_type",
|
|
11
|
+
"object_id",
|
|
12
|
+
"passwork_item_id",
|
|
13
|
+
"created",
|
|
14
|
+
"created_by",
|
|
15
|
+
]
|
|
16
|
+
list_filter = ["object_type"]
|
|
17
|
+
search_fields = ["passwork_item_id", "object_id"]
|
|
18
|
+
readonly_fields = [
|
|
19
|
+
"id",
|
|
20
|
+
"object_type",
|
|
21
|
+
"object_id",
|
|
22
|
+
"passwork_item_id",
|
|
23
|
+
"created",
|
|
24
|
+
"created_by",
|
|
25
|
+
]
|
|
26
|
+
ordering = ["-created"]
|
|
27
|
+
|
|
28
|
+
def has_add_permission(self, request):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
def has_change_permission(self, request, obj=None):
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
def has_delete_permission(self, request, obj=None):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@admin.register(PassworkAuditLog)
|
|
39
|
+
class PassworkAuditLogAdmin(admin.ModelAdmin):
|
|
40
|
+
list_display = [
|
|
41
|
+
"id",
|
|
42
|
+
"timestamp",
|
|
43
|
+
"netbox_user",
|
|
44
|
+
"passwork_item_id",
|
|
45
|
+
"object_type",
|
|
46
|
+
"object_id",
|
|
47
|
+
"action",
|
|
48
|
+
"ip_address",
|
|
49
|
+
]
|
|
50
|
+
list_filter = ["action", "object_type"]
|
|
51
|
+
search_fields = ["passwork_item_id", "netbox_user__username", "object_id"]
|
|
52
|
+
readonly_fields = [
|
|
53
|
+
"id",
|
|
54
|
+
"timestamp",
|
|
55
|
+
"netbox_user",
|
|
56
|
+
"passwork_item_id",
|
|
57
|
+
"object_type",
|
|
58
|
+
"object_id",
|
|
59
|
+
"action",
|
|
60
|
+
"ip_address",
|
|
61
|
+
]
|
|
62
|
+
ordering = ["-timestamp"]
|
|
63
|
+
|
|
64
|
+
def has_add_permission(self, request):
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def has_change_permission(self, request, obj=None):
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def has_delete_permission(self, request, obj=None):
|
|
71
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""API serializers following NetBox convention (<plugin>.api.serializers).
|
|
2
|
+
|
|
3
|
+
The NetBox events pipeline (webhooks/event rules, also triggered on changelog
|
|
4
|
+
operations) serializes the object via
|
|
5
|
+
utilities.api.get_serializer_for_model(), which looks up the serializer
|
|
6
|
+
specifically in this module by the name <Model>Serializer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from netbox_passwork.serializers import PassworkBindingSerializer
|
|
10
|
+
|
|
11
|
+
__all__ = ("PassworkBindingSerializer",)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from importlib.metadata import version as _pkg_version
|
|
2
|
+
|
|
3
|
+
from netbox.plugins import PluginConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetboxPassworkConfig(PluginConfig):
|
|
7
|
+
name = "netbox_passwork"
|
|
8
|
+
verbose_name = "NetBox Passwork Integration"
|
|
9
|
+
version = _pkg_version("netbox-passwork")
|
|
10
|
+
author = "Pavel Krasotin"
|
|
11
|
+
author_email = "krasotinpa@gmail.com"
|
|
12
|
+
description = "Passwork secrets integration for NetBox devices, VMs and services"
|
|
13
|
+
base_url = "passwork"
|
|
14
|
+
required_settings = ["PASSWORK_URL", "SESSION_ENCRYPT_KEY"]
|
|
15
|
+
default_settings = {
|
|
16
|
+
"PASSWORK_VERIFY_SSL": True,
|
|
17
|
+
"TOKEN_REFRESH_MARGIN": 60,
|
|
18
|
+
"PASSWORK_REQUEST_TIMEOUT": 5,
|
|
19
|
+
"SECRET_REVEAL_TIMEOUT": 30,
|
|
20
|
+
}
|
|
21
|
+
min_version = "4.5"
|
|
22
|
+
|
|
23
|
+
def ready(self):
|
|
24
|
+
from netbox_passwork.template_extensions import _register_views
|
|
25
|
+
|
|
26
|
+
_register_views()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
config = NetboxPassworkConfig
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
class PassworkError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Base exception of the Passwork gateway: a Passwork failure carrying HTTP meaning.
|
|
4
|
+
|
|
5
|
+
``code`` and ``http_status`` are what goes into the plugin's JSON response; ``detail``
|
|
6
|
+
is a human-readable explanation. Subclasses set the defaults, and an operation that
|
|
7
|
+
knows the failure context (login, TOTP, reading a secret) may override them when
|
|
8
|
+
raising (ADR-0001).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
code = "pw_error"
|
|
12
|
+
http_status = 502
|
|
13
|
+
detail = "Passwork error"
|
|
14
|
+
|
|
15
|
+
def __init__(self, detail: str | None = None, *, code: str | None = None, http_status: int | None = None):
|
|
16
|
+
if detail is not None:
|
|
17
|
+
self.detail = detail
|
|
18
|
+
if code is not None:
|
|
19
|
+
self.code = code
|
|
20
|
+
if http_status is not None:
|
|
21
|
+
self.http_status = http_status
|
|
22
|
+
super().__init__(self.detail)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PassworkSessionExpired(PassworkError):
|
|
26
|
+
"""Refresh token expired or was rejected — a new login is required."""
|
|
27
|
+
|
|
28
|
+
code = "pw_session_expired"
|
|
29
|
+
http_status = 401
|
|
30
|
+
detail = "Passwork session expired"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PassworkAccessDenied(PassworkError):
|
|
34
|
+
"""Passwork denied access (403 for a secret; invalid credentials on login/TOTP)."""
|
|
35
|
+
|
|
36
|
+
code = "pw_access_denied"
|
|
37
|
+
http_status = 403
|
|
38
|
+
detail = "Passwork access denied"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PassworkTimeout(PassworkError):
|
|
42
|
+
"""Passwork did not respond within PASSWORK_REQUEST_TIMEOUT seconds."""
|
|
43
|
+
|
|
44
|
+
code = "pw_timeout"
|
|
45
|
+
http_status = 504
|
|
46
|
+
detail = "Passwork timeout"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PassworkBadResponse(PassworkError):
|
|
50
|
+
"""Passwork returned a response that isn't JSON (e.g. a proxy error HTML page)."""
|
|
51
|
+
|
|
52
|
+
code = "pw_bad_response"
|
|
53
|
+
http_status = 502
|
|
54
|
+
detail = "Passwork returned a non-JSON response"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Passwork gateway — the single point through which the plugin talks to Passwork on
|
|
3
|
+
behalf of the Passwork session (see docs/adr/0001-passwork-gateway-not-middleware.md).
|
|
4
|
+
|
|
5
|
+
The module owns everything related to the Passwork session: it is the only place that
|
|
6
|
+
reads the plugin config, the only place that Fernet-encrypts its fields, reads/writes/
|
|
7
|
+
deletes its record in storage (in production, the request's Django session), refreshes
|
|
8
|
+
the access token, and provides six domain operations. Passwork failures are raised as
|
|
9
|
+
``PassworkError`` with ``code``/``http_status`` — the failure context is known to the operation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
|
|
17
|
+
from netbox_passwork.exceptions import PassworkError, PassworkSessionExpired
|
|
18
|
+
from netbox_passwork.passwork_client import PassworkAuthClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("netbox_passwork")
|
|
21
|
+
|
|
22
|
+
# The storage key and record shape are byte-for-byte compatible with pre-1.3.0
|
|
23
|
+
# versions: existing users don't get logged out on upgrade.
|
|
24
|
+
_STORAGE_KEY = "pw_session"
|
|
25
|
+
_ENCRYPTED_FIELDS = ("access_token", "refresh_token", "csrf_token")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _not_authenticated(detail: str) -> PassworkError:
|
|
29
|
+
return PassworkError(detail, code="pw_not_authenticated", http_status=401)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PassworkGateway:
|
|
33
|
+
"""
|
|
34
|
+
Six operations on behalf of the Passwork session, over storage with a ``dict`` interface.
|
|
35
|
+
|
|
36
|
+
``storage`` — where the Passwork session record lives (``request.session`` in
|
|
37
|
+
production, a plain ``dict`` in tests); ``client`` — the HTTP implementation of the
|
|
38
|
+
Passwork API; ``config`` — the plugin config (``PLUGINS_CONFIG["netbox_passwork"]``),
|
|
39
|
+
from which the gateway takes ``SESSION_ENCRYPT_KEY``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, storage, client: PassworkAuthClient, config):
|
|
43
|
+
self._storage = storage
|
|
44
|
+
self._client = client
|
|
45
|
+
key = config["SESSION_ENCRYPT_KEY"]
|
|
46
|
+
self._fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
|
47
|
+
# The gateway lives for a single HTTP request: the Passwork session that's been
|
|
48
|
+
# read (and refreshed if needed) is reused by all operations of the request —
|
|
49
|
+
# without re-decrypting the record
|
|
50
|
+
self._passwork_session: dict | None = None
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Operations
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def login(self, username: str, password: str) -> bool:
|
|
57
|
+
"""Log in to Passwork; saves the Passwork session. Returns whether TOTP is required."""
|
|
58
|
+
passwork_session = self._client.login(username, password)
|
|
59
|
+
self._write(passwork_session)
|
|
60
|
+
return bool(passwork_session["requires_totp"])
|
|
61
|
+
|
|
62
|
+
def confirm_totp(self, code: str) -> None:
|
|
63
|
+
"""Confirms TOTP for the saved Passwork session."""
|
|
64
|
+
passwork_session = self._client.confirm_totp(code, self._load())
|
|
65
|
+
self._write(passwork_session)
|
|
66
|
+
|
|
67
|
+
def get_item(self, pw_id: str) -> dict:
|
|
68
|
+
"""Passwork item with the password and custom fields decoded."""
|
|
69
|
+
return self._client.get_item(pw_id, self._load())
|
|
70
|
+
|
|
71
|
+
def list_vaults(self) -> list:
|
|
72
|
+
"""List of Passwork vaults."""
|
|
73
|
+
return self._client.list_vaults(self._load())
|
|
74
|
+
|
|
75
|
+
def search_items(self, query: str) -> list:
|
|
76
|
+
"""Search Passwork items (the query is URL-encoded by the client)."""
|
|
77
|
+
return self._client.search_items(query, self._load())
|
|
78
|
+
|
|
79
|
+
def require_session(self) -> None:
|
|
80
|
+
"""Guarantees the Passwork session exists and hasn't expired (refreshing it if needed)."""
|
|
81
|
+
self._load()
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Passwork session in storage
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _load(self) -> dict:
|
|
88
|
+
"""
|
|
89
|
+
Reads and, if needed, refreshes the Passwork session.
|
|
90
|
+
|
|
91
|
+
Known failures: no record → 401 ``pw_not_authenticated``; refresh token expired
|
|
92
|
+
or rejected → record is deleted, 401 ``pw_session_expired``; ``InvalidToken``
|
|
93
|
+
(``SESSION_ENCRYPT_KEY`` changed) → record is deleted, warning, 401
|
|
94
|
+
``pw_not_authenticated``; timeout/non-JSON during refresh → 504/502.
|
|
95
|
+
Other exceptions are not swallowed. The result is cached for the gateway's lifetime.
|
|
96
|
+
"""
|
|
97
|
+
if self._passwork_session is not None:
|
|
98
|
+
return self._passwork_session
|
|
99
|
+
record = self._storage.get(_STORAGE_KEY)
|
|
100
|
+
if record is None:
|
|
101
|
+
raise _not_authenticated("Passwork session not found")
|
|
102
|
+
try:
|
|
103
|
+
passwork_session = self._decrypt(record)
|
|
104
|
+
except InvalidToken:
|
|
105
|
+
self._delete()
|
|
106
|
+
logger.warning(
|
|
107
|
+
"Passwork session record could not be decrypted (SESSION_ENCRYPT_KEY changed?); record dropped"
|
|
108
|
+
)
|
|
109
|
+
raise _not_authenticated("Passwork session could not be decrypted") from None
|
|
110
|
+
|
|
111
|
+
before = dict(passwork_session)
|
|
112
|
+
try:
|
|
113
|
+
passwork_session = self._client.refresh_if_needed(passwork_session)
|
|
114
|
+
except PassworkSessionExpired:
|
|
115
|
+
self._delete()
|
|
116
|
+
raise
|
|
117
|
+
# Rewrite the record only if the refresh changed it — avoids unnecessary session saves
|
|
118
|
+
if passwork_session != before:
|
|
119
|
+
self._write(passwork_session)
|
|
120
|
+
self._passwork_session = passwork_session
|
|
121
|
+
return passwork_session
|
|
122
|
+
|
|
123
|
+
def _write(self, passwork_session: dict) -> None:
|
|
124
|
+
# Assigning the key by itself marks the Django session as modified
|
|
125
|
+
self._storage[_STORAGE_KEY] = self._encrypt(passwork_session)
|
|
126
|
+
self._passwork_session = passwork_session
|
|
127
|
+
|
|
128
|
+
def _delete(self) -> None:
|
|
129
|
+
self._storage.pop(_STORAGE_KEY, None)
|
|
130
|
+
self._passwork_session = None
|
|
131
|
+
|
|
132
|
+
def _encrypt(self, passwork_session: dict) -> dict:
|
|
133
|
+
return {
|
|
134
|
+
k: self._fernet.encrypt(str(v).encode()).decode() if k in _ENCRYPTED_FIELDS else v
|
|
135
|
+
for k, v in passwork_session.items()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def _decrypt(self, record: dict) -> dict:
|
|
139
|
+
return {
|
|
140
|
+
k: self._fernet.decrypt(v.encode()).decode() if k in _ENCRYPTED_FIELDS else v for k, v in record.items()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_gateway(request) -> PassworkGateway:
|
|
145
|
+
"""
|
|
146
|
+
Builds the gateway from an HTTP request — the single point of composition and the
|
|
147
|
+
only place that reads the plugin config.
|
|
148
|
+
"""
|
|
149
|
+
config = settings.PLUGINS_CONFIG["netbox_passwork"]
|
|
150
|
+
client = PassworkAuthClient(
|
|
151
|
+
base_url=config["PASSWORK_URL"],
|
|
152
|
+
verify_ssl=config.get("PASSWORK_VERIFY_SSL", True),
|
|
153
|
+
timeout=config.get("PASSWORK_REQUEST_TIMEOUT", 5),
|
|
154
|
+
refresh_margin=config.get("TOKEN_REFRESH_MARGIN", 60),
|
|
155
|
+
)
|
|
156
|
+
return PassworkGateway(request.session, client, config)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import django.db.models.deletion
|
|
2
|
+
import django.db.models.expressions
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name="PassworkBinding",
|
|
17
|
+
fields=[
|
|
18
|
+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
19
|
+
(
|
|
20
|
+
"object_type",
|
|
21
|
+
models.CharField(
|
|
22
|
+
choices=[("device", "Device"), ("vm", "Virtual Machine"), ("service", "Service")],
|
|
23
|
+
max_length=32,
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
("object_id", models.PositiveIntegerField()),
|
|
27
|
+
("passwork_item_id", models.CharField(db_index=True, max_length=128)),
|
|
28
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
29
|
+
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
|
30
|
+
(
|
|
31
|
+
"created_by",
|
|
32
|
+
models.ForeignKey(
|
|
33
|
+
null=True,
|
|
34
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
35
|
+
related_name="+",
|
|
36
|
+
to=settings.AUTH_USER_MODEL,
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
"deleted_by",
|
|
41
|
+
models.ForeignKey(
|
|
42
|
+
blank=True,
|
|
43
|
+
null=True,
|
|
44
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
45
|
+
related_name="+",
|
|
46
|
+
to=settings.AUTH_USER_MODEL,
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
],
|
|
50
|
+
options={
|
|
51
|
+
"permissions": [
|
|
52
|
+
("view_secrets", "Can view Passwork secrets"),
|
|
53
|
+
("reveal_secret", "Can reveal Passwork secret value"),
|
|
54
|
+
("add_binding", "Can create Passwork binding"),
|
|
55
|
+
("delete_binding", "Can delete Passwork binding"),
|
|
56
|
+
("view_auditlog", "Can view Passwork audit log"),
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
migrations.CreateModel(
|
|
61
|
+
name="PassworkBindingHistory",
|
|
62
|
+
fields=[
|
|
63
|
+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
64
|
+
("timestamp", models.DateTimeField(auto_now_add=True)),
|
|
65
|
+
(
|
|
66
|
+
"action",
|
|
67
|
+
models.CharField(
|
|
68
|
+
choices=[("created", "Created"), ("deleted", "Deleted"), ("restored", "Restored")],
|
|
69
|
+
max_length=16,
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
("ip_address", models.GenericIPAddressField(null=True)),
|
|
73
|
+
(
|
|
74
|
+
"binding",
|
|
75
|
+
models.ForeignKey(
|
|
76
|
+
on_delete=django.db.models.deletion.PROTECT,
|
|
77
|
+
related_name="history",
|
|
78
|
+
to="netbox_passwork.passworkbinding",
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
(
|
|
82
|
+
"netbox_user",
|
|
83
|
+
models.ForeignKey(
|
|
84
|
+
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
options={
|
|
89
|
+
"ordering": ["-timestamp"],
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
migrations.CreateModel(
|
|
93
|
+
name="PassworkAuditLog",
|
|
94
|
+
fields=[
|
|
95
|
+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
|
96
|
+
("timestamp", models.DateTimeField(auto_now_add=True)),
|
|
97
|
+
("passwork_item_id", models.CharField(max_length=128)),
|
|
98
|
+
("object_type", models.CharField(max_length=32)),
|
|
99
|
+
("object_id", models.PositiveIntegerField()),
|
|
100
|
+
("action", models.CharField(choices=[("reveal", "Reveal"), ("copy", "Copy")], max_length=16)),
|
|
101
|
+
("ip_address", models.GenericIPAddressField(null=True)),
|
|
102
|
+
(
|
|
103
|
+
"netbox_user",
|
|
104
|
+
models.ForeignKey(
|
|
105
|
+
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
],
|
|
109
|
+
options={
|
|
110
|
+
"ordering": ["-timestamp"],
|
|
111
|
+
},
|
|
112
|
+
),
|
|
113
|
+
migrations.AddIndex(
|
|
114
|
+
model_name="passworkbinding",
|
|
115
|
+
index=models.Index(
|
|
116
|
+
condition=models.Q(deleted_at__isnull=True),
|
|
117
|
+
fields=["object_type", "object_id"],
|
|
118
|
+
name="pb_active_object_idx",
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
migrations.AddConstraint(
|
|
122
|
+
model_name="passworkbinding",
|
|
123
|
+
constraint=models.UniqueConstraint(
|
|
124
|
+
condition=models.Q(deleted_at__isnull=True),
|
|
125
|
+
fields=["object_type", "object_id", "passwork_item_id"],
|
|
126
|
+
name="pb_unique_active_binding",
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
migrations.AddIndex(
|
|
130
|
+
model_name="passworkauditlog",
|
|
131
|
+
index=models.Index(fields=["netbox_user"], name="pal_user_idx"),
|
|
132
|
+
),
|
|
133
|
+
migrations.AddIndex(
|
|
134
|
+
model_name="passworkauditlog",
|
|
135
|
+
index=models.Index(fields=["passwork_item_id"], name="pal_item_idx"),
|
|
136
|
+
),
|
|
137
|
+
migrations.AddIndex(
|
|
138
|
+
model_name="passworkauditlog",
|
|
139
|
+
index=models.Index(fields=["-timestamp"], name="pal_ts_idx"),
|
|
140
|
+
),
|
|
141
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
dependencies = [
|
|
6
|
+
("netbox_passwork", "0001_initial"),
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
operations = [
|
|
10
|
+
migrations.AlterField(
|
|
11
|
+
model_name="passworkauditlog",
|
|
12
|
+
name="id",
|
|
13
|
+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
|
|
14
|
+
),
|
|
15
|
+
migrations.AlterField(
|
|
16
|
+
model_name="passworkbinding",
|
|
17
|
+
name="id",
|
|
18
|
+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
|
|
19
|
+
),
|
|
20
|
+
migrations.AlterField(
|
|
21
|
+
model_name="passworkbindinghistory",
|
|
22
|
+
name="id",
|
|
23
|
+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
|
|
24
|
+
),
|
|
25
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Move binding history over to the standard NetBox changelog.
|
|
2
|
+
|
|
3
|
+
- The PassworkBindingHistory table is dropped (accumulated records are
|
|
4
|
+
deliberately not migrated).
|
|
5
|
+
- Soft delete (deleted_at/deleted_by) is replaced with hard delete: soft-deleted
|
|
6
|
+
records are physically removed, the partial index/constraint become regular ones.
|
|
7
|
+
- PassworkBinding gains the created/last_updated fields from ChangeLoggedModel
|
|
8
|
+
(created is populated from the former created_at via RenameField).
|
|
9
|
+
|
|
10
|
+
WARNING: this migration is irreversible without a database backup — the history
|
|
11
|
+
table and soft-deleted bindings are removed permanently.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.db import migrations, models
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def purge_soft_deleted_bindings(apps, schema_editor):
|
|
19
|
+
# Raw SQL, not ORM .delete(): by this point DeleteModel has already dropped
|
|
20
|
+
# the PassworkBindingHistory table, but the PassworkBinding model still has
|
|
21
|
+
# a PROTECT back-reference to it. An ORM cascade (collector) on .delete()
|
|
22
|
+
# would hit the now-missing history table and fail with
|
|
23
|
+
# UndefinedTable. A direct DELETE bypasses the collector; history rows are
|
|
24
|
+
# already gone along with the table. The deleted_at column still exists
|
|
25
|
+
# at this point (RemoveField comes later).
|
|
26
|
+
schema_editor.execute('DELETE FROM "netbox_passwork_passworkbinding" WHERE "deleted_at" IS NOT NULL')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Migration(migrations.Migration):
|
|
30
|
+
dependencies = [
|
|
31
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
32
|
+
("netbox_passwork", "0002_alter_ids"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
operations = [
|
|
36
|
+
# History table first (FK PROTECT on PassworkBinding), then the
|
|
37
|
+
# physical removal of soft-deleted bindings — before applying the
|
|
38
|
+
# unconditional unique constraint.
|
|
39
|
+
migrations.DeleteModel(name="PassworkBindingHistory"),
|
|
40
|
+
migrations.RunPython(purge_soft_deleted_bindings, migrations.RunPython.noop),
|
|
41
|
+
# The partial index/constraint reference deleted_at — drop them
|
|
42
|
+
# before removing the field itself.
|
|
43
|
+
migrations.RemoveConstraint(
|
|
44
|
+
model_name="passworkbinding",
|
|
45
|
+
name="pb_unique_active_binding",
|
|
46
|
+
),
|
|
47
|
+
migrations.RemoveIndex(
|
|
48
|
+
model_name="passworkbinding",
|
|
49
|
+
name="pb_active_object_idx",
|
|
50
|
+
),
|
|
51
|
+
migrations.RemoveField(model_name="passworkbinding", name="deleted_at"),
|
|
52
|
+
migrations.RemoveField(model_name="passworkbinding", name="deleted_by"),
|
|
53
|
+
# created_at -> created (ChangeLoggedModel field), values are preserved.
|
|
54
|
+
migrations.RenameField(
|
|
55
|
+
model_name="passworkbinding",
|
|
56
|
+
old_name="created_at",
|
|
57
|
+
new_name="created",
|
|
58
|
+
),
|
|
59
|
+
migrations.AlterField(
|
|
60
|
+
model_name="passworkbinding",
|
|
61
|
+
name="created",
|
|
62
|
+
field=models.DateTimeField(auto_now_add=True, blank=True, null=True, verbose_name="created"),
|
|
63
|
+
),
|
|
64
|
+
migrations.AddField(
|
|
65
|
+
model_name="passworkbinding",
|
|
66
|
+
name="last_updated",
|
|
67
|
+
field=models.DateTimeField(auto_now=True, blank=True, null=True, verbose_name="last updated"),
|
|
68
|
+
),
|
|
69
|
+
migrations.AddIndex(
|
|
70
|
+
model_name="passworkbinding",
|
|
71
|
+
index=models.Index(fields=["object_type", "object_id"], name="pb_object_idx"),
|
|
72
|
+
),
|
|
73
|
+
migrations.AddConstraint(
|
|
74
|
+
model_name="passworkbinding",
|
|
75
|
+
constraint=models.UniqueConstraint(
|
|
76
|
+
fields=["object_type", "object_id", "passwork_item_id"],
|
|
77
|
+
name="pb_unique_binding",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
]
|
|
File without changes
|