netbox-secrets-manager 0.1.0__tar.gz
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_secrets_manager-0.1.0/PKG-INFO +117 -0
- netbox_secrets_manager-0.1.0/README.md +96 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/__init__.py +103 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/api/__init__.py +0 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/api/exceptions.py +28 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/api/reveal.py +30 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/api/serializers.py +191 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/api/urls.py +12 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/api/views.py +182 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/audit.py +99 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/backends/__init__.py +36 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/backends/aws.py +295 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/backends/base.py +91 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/backends/memory.py +158 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/choices.py +63 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/compat.py +61 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/config.py +117 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/constants.py +58 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/events.py +54 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/exceptions.py +80 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/filtersets.py +79 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/forms.py +315 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/graphql/__init__.py +0 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/graphql/filters.py +38 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/graphql/schema.py +26 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/graphql/types.py +135 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/graphql_extensions.py +65 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/management/__init__.py +0 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/management/commands/__init__.py +0 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/management/commands/nbsm_reconcile.py +154 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/migrations/0001_initial.py +129 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/migrations/__init__.py +0 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/models.py +291 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/naming.py +94 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/navigation.py +46 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/schema.py +456 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/search.py +36 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/services.py +207 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/signals.py +126 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tables.py +76 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/template_content.py +119 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/templates/netbox_secrets_manager/inc/secrets_panel.html +41 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/templates/netbox_secrets_manager/object_secrets.html +4 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/templates/netbox_secrets_manager/secret.html +179 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/templates/netbox_secrets_manager/secrettype.html +69 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/__init__.py +0 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_api.py +358 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_backend.py +353 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_forms.py +281 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_models.py +313 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_naming.py +67 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_no_plaintext.py +250 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_permissions.py +265 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_reconcile.py +109 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_schema.py +257 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/test_services.py +243 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/tests/utils.py +154 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/urls.py +27 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager/views.py +276 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager.egg-info/PKG-INFO +117 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager.egg-info/SOURCES.txt +64 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager.egg-info/dependency_links.txt +1 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager.egg-info/requires.txt +5 -0
- netbox_secrets_manager-0.1.0/netbox_secrets_manager.egg-info/top_level.txt +1 -0
- netbox_secrets_manager-0.1.0/pyproject.toml +64 -0
- netbox_secrets_manager-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netbox-secrets-manager
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Attach secrets to NetBox objects, backed by AWS Secrets Manager
|
|
5
|
+
Author: SF Compute
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/sfcompute/netbox-secrets-manager
|
|
8
|
+
Project-URL: Source, https://github.com/sfcompute/netbox-secrets-manager
|
|
9
|
+
Keywords: netbox,netbox-plugin,secrets,aws,secrets-manager
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: boto3>=1.34
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: moto[secretsmanager,sts]>=5.0; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# netbox-secrets-manager
|
|
23
|
+
|
|
24
|
+
Attach secrets to NetBox objects — BMC credentials on a device, a private
|
|
25
|
+
certificate on a virtual machine — while keeping **AWS Secrets Manager as the
|
|
26
|
+
system of record**. NetBox stores only metadata: the ARN, which object the
|
|
27
|
+
secret belongs to, and which shape it has.
|
|
28
|
+
|
|
29
|
+
No field on any model in this plugin ever holds plaintext or ciphertext.
|
|
30
|
+
|
|
31
|
+
## Why not netbox-secrets?
|
|
32
|
+
|
|
33
|
+
[netbox-secrets](https://github.com/Onemind-Services-LLC/netbox-secrets)
|
|
34
|
+
encrypts secrets inside NetBox: a per-user RSA keypair wraps a per-install
|
|
35
|
+
master key, which wraps a session key, which encrypts each value. Decryption is
|
|
36
|
+
a *cryptographic* capability, held by whoever has the private key.
|
|
37
|
+
|
|
38
|
+
That makes one common arrangement impossible to express: an operator who may
|
|
39
|
+
rotate a BMC password but may not read it. This plugin moves storage to AWS and
|
|
40
|
+
makes decryption a *permission* instead, so `view` (list without revealing),
|
|
41
|
+
`decrypt` (reveal), `add`/`change` (write) and `delete` are granted separately.
|
|
42
|
+
|
|
43
|
+
The trade-off is explicit and you should read
|
|
44
|
+
[docs/security.md](docs/security.md) before deploying: the application's IAM
|
|
45
|
+
role holds `GetSecretValue` at all times, so `decrypt` is an application-layer
|
|
46
|
+
control, not an IAM one.
|
|
47
|
+
|
|
48
|
+
## What it does
|
|
49
|
+
|
|
50
|
+
- **Typed secrets.** A `SecretType` carries a JSON Schema, so "BMC Credentials"
|
|
51
|
+
(`username`, `password`) and "Private Cert" (one large `certificate` field)
|
|
52
|
+
are data an admin edits, not code.
|
|
53
|
+
- **Four separable permissions**, including a custom `decrypt` action with
|
|
54
|
+
object-level constraints — scope reveal to one secret type, or one app.
|
|
55
|
+
- **Partial writes without reading.** An operator with `change` but not
|
|
56
|
+
`decrypt` can rotate one field; the server merges server-side and returns
|
|
57
|
+
nothing.
|
|
58
|
+
- **REST and GraphQL**, with plaintext reachable only through explicit,
|
|
59
|
+
separately-permissioned endpoints.
|
|
60
|
+
- **Lifecycle management.** The plugin names, creates and deletes the AWS
|
|
61
|
+
secret, and `nbsm_reconcile` reports drift in both directions.
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- NetBox **4.6.0** or later (4.7 supported)
|
|
66
|
+
- Python 3.12+
|
|
67
|
+
- An AWS account, and credentials reachable by the default boto3 chain
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install netbox-secrets-manager
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Add to `configuration.py`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
PLUGINS = ['netbox_secrets_manager']
|
|
79
|
+
|
|
80
|
+
PLUGINS_CONFIG = {
|
|
81
|
+
'netbox_secrets_manager': {
|
|
82
|
+
'apps': ['dcim.device', 'virtualization.virtualmachine'],
|
|
83
|
+
'aws_region': 'us-east-1',
|
|
84
|
+
'instance_id': 'prod-netbox',
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then run migrations and collect static files. Full options are documented in
|
|
90
|
+
[docs/configuration.md](docs/configuration.md).
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
Tasks run through [just](https://github.com/casey/just); `just` on its own lists
|
|
95
|
+
everything.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
just up # NetBox + Postgres + Redis + LocalStack
|
|
99
|
+
just test # the suite
|
|
100
|
+
just check # lint, migration drift and tests, as CI runs them
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
NetBox comes up on <http://localhost:8000> as `admin` / `admin`, with LocalStack
|
|
104
|
+
standing in for AWS.
|
|
105
|
+
|
|
106
|
+
The suite runs inside the stack rather than a local virtualenv, because it is
|
|
107
|
+
built on NetBox's own test utilities and needs NetBox's settings, database and
|
|
108
|
+
app registry.
|
|
109
|
+
|
|
110
|
+
`just test-integration` additionally runs the backend contract against
|
|
111
|
+
LocalStack. It is worth running before a release: moto is not faithful on two
|
|
112
|
+
behaviours this plugin depends on, so those tests are the only coverage of the
|
|
113
|
+
adopt and restore-previous paths against a real Secrets Manager implementation.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
Apache-2.0
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# netbox-secrets-manager
|
|
2
|
+
|
|
3
|
+
Attach secrets to NetBox objects — BMC credentials on a device, a private
|
|
4
|
+
certificate on a virtual machine — while keeping **AWS Secrets Manager as the
|
|
5
|
+
system of record**. NetBox stores only metadata: the ARN, which object the
|
|
6
|
+
secret belongs to, and which shape it has.
|
|
7
|
+
|
|
8
|
+
No field on any model in this plugin ever holds plaintext or ciphertext.
|
|
9
|
+
|
|
10
|
+
## Why not netbox-secrets?
|
|
11
|
+
|
|
12
|
+
[netbox-secrets](https://github.com/Onemind-Services-LLC/netbox-secrets)
|
|
13
|
+
encrypts secrets inside NetBox: a per-user RSA keypair wraps a per-install
|
|
14
|
+
master key, which wraps a session key, which encrypts each value. Decryption is
|
|
15
|
+
a *cryptographic* capability, held by whoever has the private key.
|
|
16
|
+
|
|
17
|
+
That makes one common arrangement impossible to express: an operator who may
|
|
18
|
+
rotate a BMC password but may not read it. This plugin moves storage to AWS and
|
|
19
|
+
makes decryption a *permission* instead, so `view` (list without revealing),
|
|
20
|
+
`decrypt` (reveal), `add`/`change` (write) and `delete` are granted separately.
|
|
21
|
+
|
|
22
|
+
The trade-off is explicit and you should read
|
|
23
|
+
[docs/security.md](docs/security.md) before deploying: the application's IAM
|
|
24
|
+
role holds `GetSecretValue` at all times, so `decrypt` is an application-layer
|
|
25
|
+
control, not an IAM one.
|
|
26
|
+
|
|
27
|
+
## What it does
|
|
28
|
+
|
|
29
|
+
- **Typed secrets.** A `SecretType` carries a JSON Schema, so "BMC Credentials"
|
|
30
|
+
(`username`, `password`) and "Private Cert" (one large `certificate` field)
|
|
31
|
+
are data an admin edits, not code.
|
|
32
|
+
- **Four separable permissions**, including a custom `decrypt` action with
|
|
33
|
+
object-level constraints — scope reveal to one secret type, or one app.
|
|
34
|
+
- **Partial writes without reading.** An operator with `change` but not
|
|
35
|
+
`decrypt` can rotate one field; the server merges server-side and returns
|
|
36
|
+
nothing.
|
|
37
|
+
- **REST and GraphQL**, with plaintext reachable only through explicit,
|
|
38
|
+
separately-permissioned endpoints.
|
|
39
|
+
- **Lifecycle management.** The plugin names, creates and deletes the AWS
|
|
40
|
+
secret, and `nbsm_reconcile` reports drift in both directions.
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- NetBox **4.6.0** or later (4.7 supported)
|
|
45
|
+
- Python 3.12+
|
|
46
|
+
- An AWS account, and credentials reachable by the default boto3 chain
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install netbox-secrets-manager
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Add to `configuration.py`:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
PLUGINS = ['netbox_secrets_manager']
|
|
58
|
+
|
|
59
|
+
PLUGINS_CONFIG = {
|
|
60
|
+
'netbox_secrets_manager': {
|
|
61
|
+
'apps': ['dcim.device', 'virtualization.virtualmachine'],
|
|
62
|
+
'aws_region': 'us-east-1',
|
|
63
|
+
'instance_id': 'prod-netbox',
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then run migrations and collect static files. Full options are documented in
|
|
69
|
+
[docs/configuration.md](docs/configuration.md).
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
Tasks run through [just](https://github.com/casey/just); `just` on its own lists
|
|
74
|
+
everything.
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
just up # NetBox + Postgres + Redis + LocalStack
|
|
78
|
+
just test # the suite
|
|
79
|
+
just check # lint, migration drift and tests, as CI runs them
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
NetBox comes up on <http://localhost:8000> as `admin` / `admin`, with LocalStack
|
|
83
|
+
standing in for AWS.
|
|
84
|
+
|
|
85
|
+
The suite runs inside the stack rather than a local virtualenv, because it is
|
|
86
|
+
built on NetBox's own test utilities and needs NetBox's settings, database and
|
|
87
|
+
app registry.
|
|
88
|
+
|
|
89
|
+
`just test-integration` additionally runs the backend contract against
|
|
90
|
+
LocalStack. It is worth running before a release: moto is not faithful on two
|
|
91
|
+
behaviours this plugin depends on, so those tests are the only coverage of the
|
|
92
|
+
adopt and restore-previous paths against a real Secrets Manager implementation.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Apache-2.0
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""NetBox plugin for attaching secrets to objects, backed by AWS Secrets Manager.
|
|
2
|
+
|
|
3
|
+
NetBox stores only metadata: the ARN, which object a secret belongs to, and
|
|
4
|
+
which shape it has. AWS Secrets Manager is the system of record. No field on
|
|
5
|
+
any model in this package ever holds plaintext or ciphertext.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from netbox.plugins import PluginConfig
|
|
9
|
+
|
|
10
|
+
__version__ = '0.1.0'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecretsManagerConfig(PluginConfig):
|
|
14
|
+
name = 'netbox_secrets_manager'
|
|
15
|
+
verbose_name = 'Secrets Manager'
|
|
16
|
+
description = 'Attach secrets to NetBox objects, backed by AWS Secrets Manager'
|
|
17
|
+
version = __version__
|
|
18
|
+
author = 'SF Compute'
|
|
19
|
+
base_url = 'secrets-manager'
|
|
20
|
+
|
|
21
|
+
# 4.6.0 is the floor because custom model actions (the `decrypt` permission)
|
|
22
|
+
# were introduced there. The upper bound is deliberate rather than open:
|
|
23
|
+
# this plugin reaches into a handful of undocumented internals (see
|
|
24
|
+
# compat.py), so a new major should be tested before it is allowed.
|
|
25
|
+
min_version = '4.6.0'
|
|
26
|
+
max_version = '4.7.99'
|
|
27
|
+
|
|
28
|
+
menu = 'navigation.menu'
|
|
29
|
+
search_indexes = 'search.indexes'
|
|
30
|
+
# Points at the list inside the module, not the module itself.
|
|
31
|
+
graphql_schema = 'graphql.schema.schema'
|
|
32
|
+
# Ignored on 4.6, which has no collection point for it. The module guards
|
|
33
|
+
# itself as well, so it is inert there even if something force-imports it.
|
|
34
|
+
graphql_type_extensions = 'graphql_extensions.type_extensions'
|
|
35
|
+
graphql_filter_extensions = 'graphql_extensions.filter_extensions'
|
|
36
|
+
|
|
37
|
+
required_settings = ['apps']
|
|
38
|
+
|
|
39
|
+
default_settings = {
|
|
40
|
+
# --- AWS -----------------------------------------------------------
|
|
41
|
+
'aws_region': 'us-east-1',
|
|
42
|
+
# Left None to use the default boto3 credential chain: IRSA, instance
|
|
43
|
+
# profile, environment, ~/.aws. Set to assume a role instead.
|
|
44
|
+
'assume_role_arn': None,
|
|
45
|
+
'assume_role_session_name': 'netbox-secrets-manager',
|
|
46
|
+
'assume_role_external_id': None,
|
|
47
|
+
# LocalStack or a VPC endpoint.
|
|
48
|
+
'endpoint_url': None,
|
|
49
|
+
# None means AWS's own aws/secretsmanager key.
|
|
50
|
+
'kms_key_id': None,
|
|
51
|
+
# Merged into the botocore Config; see backends/aws.py for the defaults
|
|
52
|
+
# this overrides.
|
|
53
|
+
'boto_config': {},
|
|
54
|
+
# Tagged onto every secret this install owns. Two NetBox instances
|
|
55
|
+
# sharing one AWS account MUST set different values, or each will treat
|
|
56
|
+
# the other's secrets as its own and may overwrite them.
|
|
57
|
+
'instance_id': 'default',
|
|
58
|
+
# --- Naming and lifecycle -------------------------------------------
|
|
59
|
+
'secret_name_template': 'netbox/{app_label}/{model}/{object_id}/{secret_type}/{name}',
|
|
60
|
+
'secret_tags': {'ManagedBy': 'netbox'},
|
|
61
|
+
# 7-30 schedules deletion with that recovery window; 0 force-deletes
|
|
62
|
+
# immediately; None leaves the AWS secret alone and only drops NetBox's
|
|
63
|
+
# metadata row.
|
|
64
|
+
'deletion_recovery_days': 30,
|
|
65
|
+
# --- Limits ---------------------------------------------------------
|
|
66
|
+
# Each decrypt is one GetSecretValue call. Without a cap, one bulk
|
|
67
|
+
# request or one GraphQL query can turn into thousands.
|
|
68
|
+
'bulk_decrypt_max': 100,
|
|
69
|
+
'graphql_decrypt_max': 25,
|
|
70
|
+
# --- UI -------------------------------------------------------------
|
|
71
|
+
# Vocabulary matches netbox-secrets so the migration is familiar.
|
|
72
|
+
'display_default': 'right_page',
|
|
73
|
+
'display_setting': {},
|
|
74
|
+
'top_level_menu': True,
|
|
75
|
+
# --- Backend --------------------------------------------------------
|
|
76
|
+
# Swap for 'netbox_secrets_manager.backends.memory.MemoryBackend' in
|
|
77
|
+
# tests, or point at your own SecretBackend subclass.
|
|
78
|
+
'backend': 'netbox_secrets_manager.backends.aws.AWSSecretsManagerBackend',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def ready(self):
|
|
82
|
+
super().ready()
|
|
83
|
+
|
|
84
|
+
from .config import validate_config
|
|
85
|
+
|
|
86
|
+
# A bad `apps` entry or an illegal recovery window is a deployment
|
|
87
|
+
# mistake. Surface it at startup, not at first write.
|
|
88
|
+
validate_config()
|
|
89
|
+
|
|
90
|
+
# Imported for their registration side effects only:
|
|
91
|
+
# events - registers the event types webhooks and event rules fire on.
|
|
92
|
+
# signals - AWS deletion on row delete, plus the post_delete receivers
|
|
93
|
+
# that clean up secrets orphaned by a deleted parent object
|
|
94
|
+
# (a GenericForeignKey does not cascade).
|
|
95
|
+
from . import ( # noqa: F401
|
|
96
|
+
events,
|
|
97
|
+
signals,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
signals.connect_orphan_receivers()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
config = SecretsManagerConfig
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Mapping backend failures onto HTTP statuses.
|
|
2
|
+
|
|
3
|
+
Without this, every AWS problem - throttling, an expired role, a network blip -
|
|
4
|
+
surfaces as a bare 500 that says nothing useful and looks like a plugin bug.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rest_framework.response import Response
|
|
8
|
+
from rest_framework.views import exception_handler as drf_exception_handler
|
|
9
|
+
|
|
10
|
+
from ..exceptions import SchemaValidationError, SecretBackendError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def secrets_exception_handler(exc, context):
|
|
14
|
+
"""DRF exception handler covering our own exception families.
|
|
15
|
+
|
|
16
|
+
Wire up with ``REST_FRAMEWORK['EXCEPTION_HANDLER']``, or rely on the
|
|
17
|
+
viewset's ``handle_exception`` override, which calls this directly.
|
|
18
|
+
"""
|
|
19
|
+
if isinstance(exc, SchemaValidationError):
|
|
20
|
+
return Response(exc.errors, status=400)
|
|
21
|
+
|
|
22
|
+
if isinstance(exc, SecretBackendError):
|
|
23
|
+
return Response(
|
|
24
|
+
{'detail': str(exc), 'aws_code': exc.aws_code},
|
|
25
|
+
status=exc.http_status,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return drf_exception_handler(exc, context)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The only serializers in this package that can represent a secret value.
|
|
2
|
+
|
|
3
|
+
Kept in its own module, imported solely by the decrypt actions, and never set
|
|
4
|
+
as a viewset's ``serializer_class``. That last point matters for more than
|
|
5
|
+
tidiness: it keeps drf-spectacular from attaching this shape to any CRUD
|
|
6
|
+
operation in the generated schema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from rest_framework import serializers
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SecretValueSerializer(serializers.Serializer):
|
|
13
|
+
"""A decrypted secret. Output only."""
|
|
14
|
+
|
|
15
|
+
id = serializers.IntegerField(read_only=True)
|
|
16
|
+
name = serializers.CharField(read_only=True)
|
|
17
|
+
secret_type = serializers.CharField(read_only=True)
|
|
18
|
+
data = serializers.DictField(read_only=True)
|
|
19
|
+
retrieved_at = serializers.DateTimeField(read_only=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BulkDecryptSerializer(serializers.Serializer):
|
|
23
|
+
"""Input for the bulk decrypt endpoint."""
|
|
24
|
+
|
|
25
|
+
ids = serializers.ListField(
|
|
26
|
+
child=serializers.IntegerField(),
|
|
27
|
+
allow_empty=False,
|
|
28
|
+
help_text='Secret IDs to decrypt. IDs the caller may not decrypt are omitted from the response '
|
|
29
|
+
'rather than raising, so a partial grant still returns what it can.',
|
|
30
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Serializers that cannot emit a secret value.
|
|
2
|
+
|
|
3
|
+
This module contains **no serializer capable of representing plaintext**. The
|
|
4
|
+
one that can lives in ``reveal.py`` and is imported only by the decrypt
|
|
5
|
+
actions. That split is the first and strongest of the guarantees here, because
|
|
6
|
+
it means no amount of editing this file can accidentally expose a value through
|
|
7
|
+
the CRUD endpoints.
|
|
8
|
+
|
|
9
|
+
The rest, in descending order of strength:
|
|
10
|
+
|
|
11
|
+
* ``fields`` is enumerated, never ``__all__``, so a new model field cannot
|
|
12
|
+
appear in the API by accident.
|
|
13
|
+
* ``data``/``clear``/``merge`` are ``write_only``, which DRF enforces
|
|
14
|
+
structurally: ``to_representation`` iterates ``_readable_fields``, which
|
|
15
|
+
excludes them. There is no code path that includes them.
|
|
16
|
+
* A defensive strip in ``to_representation``, which a test asserts is dead code.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from core.models import ObjectType
|
|
20
|
+
from netbox.api.fields import ContentTypeField
|
|
21
|
+
from netbox.api.gfk_fields import GFKSerializerField
|
|
22
|
+
from netbox.api.serializers import NetBoxModelSerializer
|
|
23
|
+
from rest_framework import serializers
|
|
24
|
+
|
|
25
|
+
from ..constants import WRITE_ONLY_FIELDS
|
|
26
|
+
from ..models import Secret, SecretType
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecretTypeSerializer(NetBoxModelSerializer):
|
|
30
|
+
url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets_manager-api:secrettype-detail')
|
|
31
|
+
secret_count = serializers.IntegerField(read_only=True)
|
|
32
|
+
|
|
33
|
+
class Meta:
|
|
34
|
+
model = SecretType
|
|
35
|
+
fields = (
|
|
36
|
+
'id',
|
|
37
|
+
'url',
|
|
38
|
+
'display_url',
|
|
39
|
+
'display',
|
|
40
|
+
'name',
|
|
41
|
+
'slug',
|
|
42
|
+
'description',
|
|
43
|
+
'schema',
|
|
44
|
+
'name_template',
|
|
45
|
+
'kms_key_id',
|
|
46
|
+
'secret_count',
|
|
47
|
+
'comments',
|
|
48
|
+
'tags',
|
|
49
|
+
'custom_fields',
|
|
50
|
+
'created',
|
|
51
|
+
'last_updated',
|
|
52
|
+
)
|
|
53
|
+
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SecretSerializer(NetBoxModelSerializer):
|
|
57
|
+
url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_secrets_manager-api:secret-detail')
|
|
58
|
+
object_type = ContentTypeField(queryset=ObjectType.objects.all())
|
|
59
|
+
assigned_object = GFKSerializerField(read_only=True)
|
|
60
|
+
secret_type = SecretTypeSerializer(nested=True)
|
|
61
|
+
|
|
62
|
+
# Declared explicitly because editable=False keeps ModelSerializer from
|
|
63
|
+
# generating them at all, and read_only so they can never be written: the
|
|
64
|
+
# ARN is the plugin's own bookkeeping, not user input.
|
|
65
|
+
arn = serializers.CharField(read_only=True)
|
|
66
|
+
aws_secret_name = serializers.CharField(read_only=True)
|
|
67
|
+
version_id = serializers.CharField(read_only=True)
|
|
68
|
+
has_previous_version = serializers.BooleanField(read_only=True)
|
|
69
|
+
last_written = serializers.DateTimeField(read_only=True)
|
|
70
|
+
|
|
71
|
+
# The write path. write_only means DRF never includes these in output.
|
|
72
|
+
data = serializers.DictField(
|
|
73
|
+
write_only=True,
|
|
74
|
+
required=False,
|
|
75
|
+
help_text='Field values to write. On PATCH, fields omitted here keep their current value.',
|
|
76
|
+
)
|
|
77
|
+
clear = serializers.ListField(
|
|
78
|
+
child=serializers.CharField(),
|
|
79
|
+
write_only=True,
|
|
80
|
+
required=False,
|
|
81
|
+
help_text='Field names to set to an empty value. Distinct from omitting them, which leaves them alone.',
|
|
82
|
+
)
|
|
83
|
+
merge = serializers.BooleanField(
|
|
84
|
+
write_only=True,
|
|
85
|
+
default=True,
|
|
86
|
+
help_text='When false, the submitted data replaces the stored value wholesale and must satisfy '
|
|
87
|
+
'every required field.',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
class Meta:
|
|
91
|
+
model = Secret
|
|
92
|
+
fields = (
|
|
93
|
+
'id',
|
|
94
|
+
'url',
|
|
95
|
+
'display_url',
|
|
96
|
+
'display',
|
|
97
|
+
'object_type',
|
|
98
|
+
'object_id',
|
|
99
|
+
'assigned_object',
|
|
100
|
+
'secret_type',
|
|
101
|
+
'name',
|
|
102
|
+
'arn',
|
|
103
|
+
'aws_secret_name',
|
|
104
|
+
'version_id',
|
|
105
|
+
'has_previous_version',
|
|
106
|
+
'last_written',
|
|
107
|
+
'data',
|
|
108
|
+
'clear',
|
|
109
|
+
'merge',
|
|
110
|
+
'description',
|
|
111
|
+
'comments',
|
|
112
|
+
'tags',
|
|
113
|
+
'custom_fields',
|
|
114
|
+
'created',
|
|
115
|
+
'last_updated',
|
|
116
|
+
)
|
|
117
|
+
brief_fields = ('id', 'url', 'display', 'name', 'secret_type')
|
|
118
|
+
|
|
119
|
+
def validate(self, data):
|
|
120
|
+
"""Keep the write-only keys away from the model constructor.
|
|
121
|
+
|
|
122
|
+
NetBox's serializer base instantiates ``Meta.model(**attrs)`` to run
|
|
123
|
+
model-level validation. ``data``/``clear``/``merge`` are serializer
|
|
124
|
+
inputs, not model fields, so they have to be lifted out first and put
|
|
125
|
+
back afterwards for create()/update() to consume.
|
|
126
|
+
"""
|
|
127
|
+
carried = {key: data.pop(key) for key in list(WRITE_ONLY_FIELDS) if key in data}
|
|
128
|
+
data = super().validate(data)
|
|
129
|
+
data.update(carried)
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
def to_representation(self, instance):
|
|
133
|
+
representation = super().to_representation(instance)
|
|
134
|
+
# Belt and braces. write_only already excludes these; if this loop ever
|
|
135
|
+
# removes anything, DRF's contract has changed underneath us and
|
|
136
|
+
# test_no_plaintext will say so.
|
|
137
|
+
for key in WRITE_ONLY_FIELDS & representation.keys():
|
|
138
|
+
del representation[key]
|
|
139
|
+
return representation
|
|
140
|
+
|
|
141
|
+
def create(self, validated_data):
|
|
142
|
+
from .. import services
|
|
143
|
+
|
|
144
|
+
data = validated_data.pop('data', None)
|
|
145
|
+
validated_data.pop('clear', None)
|
|
146
|
+
validated_data.pop('merge', None)
|
|
147
|
+
|
|
148
|
+
if not data:
|
|
149
|
+
raise serializers.ValidationError({'data': 'This field is required when creating a secret.'})
|
|
150
|
+
|
|
151
|
+
tags = validated_data.pop('tags', None)
|
|
152
|
+
custom_fields = validated_data.pop('custom_field_data', None)
|
|
153
|
+
|
|
154
|
+
secret = Secret(**validated_data)
|
|
155
|
+
if custom_fields is not None:
|
|
156
|
+
secret.custom_field_data = custom_fields
|
|
157
|
+
|
|
158
|
+
secret = services.create_secret(secret, data, user=self._user(), request=self._request())
|
|
159
|
+
if tags is not None:
|
|
160
|
+
secret.tags.set(tags)
|
|
161
|
+
return secret
|
|
162
|
+
|
|
163
|
+
def update(self, instance, validated_data):
|
|
164
|
+
from .. import services
|
|
165
|
+
|
|
166
|
+
data = validated_data.pop('data', {})
|
|
167
|
+
clear = set(validated_data.pop('clear', ()) or ())
|
|
168
|
+
merge = validated_data.pop('merge', True)
|
|
169
|
+
tags = validated_data.pop('tags', None)
|
|
170
|
+
|
|
171
|
+
for key, value in validated_data.items():
|
|
172
|
+
setattr(instance, key, value)
|
|
173
|
+
# Runs the set-once check on object_type/object_id/secret_type.
|
|
174
|
+
instance.full_clean(exclude=('arn', 'aws_secret_name', 'version_id'))
|
|
175
|
+
instance.save()
|
|
176
|
+
|
|
177
|
+
if tags is not None:
|
|
178
|
+
instance.tags.set(tags)
|
|
179
|
+
|
|
180
|
+
if data or clear:
|
|
181
|
+
instance = services.update_secret(
|
|
182
|
+
instance, data, clear=clear, merge=merge, user=self._user(), request=self._request()
|
|
183
|
+
)
|
|
184
|
+
return instance
|
|
185
|
+
|
|
186
|
+
def _request(self):
|
|
187
|
+
return self.context.get('request')
|
|
188
|
+
|
|
189
|
+
def _user(self):
|
|
190
|
+
request = self._request()
|
|
191
|
+
return getattr(request, 'user', None) if request else None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""API routing. Mounts at /api/plugins/secrets-manager/."""
|
|
2
|
+
|
|
3
|
+
from netbox.api.routers import NetBoxRouter
|
|
4
|
+
|
|
5
|
+
from . import views
|
|
6
|
+
|
|
7
|
+
router = NetBoxRouter()
|
|
8
|
+
router.register('secrets', views.SecretViewSet)
|
|
9
|
+
router.register('secret-types', views.SecretTypeViewSet)
|
|
10
|
+
|
|
11
|
+
app_name = 'netbox_secrets_manager-api'
|
|
12
|
+
urlpatterns = router.urls
|