granny-devops 0.9.2__tar.gz → 0.9.3__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.
- {granny_devops-0.9.2 → granny_devops-0.9.3}/PKG-INFO +10 -1
- {granny_devops-0.9.2 → granny_devops-0.9.3}/README.md +9 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/__init__.py +1 -1
- granny_devops-0.9.3/granny/cli/elk.py +192 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/main.py +5 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/credentials/secrets.py +4 -0
- granny_devops-0.9.3/granny/elk/__init__.py +5 -0
- granny_devops-0.9.3/granny/elk/client.py +149 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/pyproject.toml +1 -1
- {granny_devops-0.9.2 → granny_devops-0.9.3}/.gitignore +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/LICENSE +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/costs.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/credits.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/authentik/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/authentik/client.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/authentik/provision.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/analyze.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/authentik.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/cdn.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/create.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/credentials.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/dns.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/docker.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/edge.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/email.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/serverless.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cli/storage.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/registrars.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/base.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/desec.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/factory.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/inwx.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/manual.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/dns/records.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/docker/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/docker/build_base.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/edge/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/edge/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/mailjet.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/email/workmail.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/report.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/__init__.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/aws.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/bunny.py +0 -0
- {granny_devops-0.9.2 → granny_devops-0.9.3}/granny/storage/hetzner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: granny-devops
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.3
|
|
4
4
|
Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
|
|
5
5
|
Author-email: Martin Wieser <martin.wieser@pseekoo.com>
|
|
6
6
|
License: MIT License
|
|
@@ -221,6 +221,7 @@ Common keys:
|
|
|
221
221
|
| ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
|
|
222
222
|
| INWX | `INWX_USERNAME`, `INWX_PASSWORD`, `INWX_SHARED_SECRET` (only with 2FA) |
|
|
223
223
|
| Docker Hub | `DOCKER_HUB_USER`, `DOCKER_HUB_TOKEN` |
|
|
224
|
+
| Elasticsearch / Kibana | `ELASTICSEARCH_URL`, `ELASTICSEARCH_API_KEY` or `ELASTICSEARCH_USERNAME` + `ELASTICSEARCH_PASSWORD` |
|
|
224
225
|
|
|
225
226
|
Set only the ones you need. Use `granny credentials status` to verify
|
|
226
227
|
what's configured at any time.
|
|
@@ -297,6 +298,13 @@ granny authentik list providers
|
|
|
297
298
|
granny authentik rotate-secret my-oauth-provider
|
|
298
299
|
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
299
300
|
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
301
|
+
|
|
302
|
+
# Elasticsearch / Kibana users
|
|
303
|
+
granny elk add-user user@example.com \
|
|
304
|
+
--email user@example.com \
|
|
305
|
+
--full-name "Example User" \
|
|
306
|
+
--role kibana_admin \
|
|
307
|
+
--generate-password
|
|
300
308
|
```
|
|
301
309
|
|
|
302
310
|
## Capability matrix
|
|
@@ -314,6 +322,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
314
322
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
315
323
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
316
324
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
325
|
+
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
317
326
|
|
|
318
327
|
## As a library
|
|
319
328
|
|
|
@@ -89,6 +89,7 @@ Common keys:
|
|
|
89
89
|
| ClouDNS | `CLOUDNS_AUTH_ID`/`_PASSWORD` (or `_SUB_AUTH_ID`/`_SUB_AUTH_USER`) |
|
|
90
90
|
| INWX | `INWX_USERNAME`, `INWX_PASSWORD`, `INWX_SHARED_SECRET` (only with 2FA) |
|
|
91
91
|
| Docker Hub | `DOCKER_HUB_USER`, `DOCKER_HUB_TOKEN` |
|
|
92
|
+
| Elasticsearch / Kibana | `ELASTICSEARCH_URL`, `ELASTICSEARCH_API_KEY` or `ELASTICSEARCH_USERNAME` + `ELASTICSEARCH_PASSWORD` |
|
|
92
93
|
|
|
93
94
|
Set only the ones you need. Use `granny credentials status` to verify
|
|
94
95
|
what's configured at any time.
|
|
@@ -165,6 +166,13 @@ granny authentik list providers
|
|
|
165
166
|
granny authentik rotate-secret my-oauth-provider
|
|
166
167
|
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
167
168
|
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
169
|
+
|
|
170
|
+
# Elasticsearch / Kibana users
|
|
171
|
+
granny elk add-user user@example.com \
|
|
172
|
+
--email user@example.com \
|
|
173
|
+
--full-name "Example User" \
|
|
174
|
+
--role kibana_admin \
|
|
175
|
+
--generate-password
|
|
168
176
|
```
|
|
169
177
|
|
|
170
178
|
## Capability matrix
|
|
@@ -182,6 +190,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
182
190
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
183
191
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
184
192
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
193
|
+
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
185
194
|
|
|
186
195
|
## As a library
|
|
187
196
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""``granny elk`` — manage Elasticsearch / Kibana users."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from granny.elk.client import (
|
|
13
|
+
ElasticsearchSecurityClient,
|
|
14
|
+
ElasticsearchSecurityError,
|
|
15
|
+
generate_password as generate_elk_password,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def elk() -> None:
|
|
21
|
+
"""Manage Elasticsearch / Kibana security objects."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _client(
|
|
25
|
+
url: str | None,
|
|
26
|
+
admin_username: str | None,
|
|
27
|
+
admin_password: str | None,
|
|
28
|
+
api_key: str | None,
|
|
29
|
+
verify_tls: bool,
|
|
30
|
+
) -> ElasticsearchSecurityClient:
|
|
31
|
+
try:
|
|
32
|
+
return ElasticsearchSecurityClient.from_environment(
|
|
33
|
+
base_url=url,
|
|
34
|
+
username=admin_username,
|
|
35
|
+
password=admin_password,
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
verify_tls=verify_tls,
|
|
38
|
+
)
|
|
39
|
+
except ElasticsearchSecurityError as exc:
|
|
40
|
+
raise click.ClickException(str(exc)) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_COMMON_OPTIONS = [
|
|
44
|
+
click.option("--url", default=None, help="Elasticsearch URL (env: ELASTICSEARCH_URL)."),
|
|
45
|
+
click.option(
|
|
46
|
+
"--admin-username",
|
|
47
|
+
default=None,
|
|
48
|
+
help="Admin username (env/vault: ELASTICSEARCH_USERNAME).",
|
|
49
|
+
),
|
|
50
|
+
click.option(
|
|
51
|
+
"--admin-password",
|
|
52
|
+
default=None,
|
|
53
|
+
help="Admin password (env/vault: ELASTICSEARCH_PASSWORD).",
|
|
54
|
+
),
|
|
55
|
+
click.option(
|
|
56
|
+
"--api-key",
|
|
57
|
+
default=None,
|
|
58
|
+
help="Elasticsearch API key (env/vault: ELASTICSEARCH_API_KEY).",
|
|
59
|
+
),
|
|
60
|
+
click.option("--verify-tls/--no-verify-tls", default=True, show_default=True),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def common_options(func: Any) -> Any:
|
|
65
|
+
for option in reversed(_COMMON_OPTIONS):
|
|
66
|
+
func = option(func)
|
|
67
|
+
return func
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@elk.command("add-user")
|
|
71
|
+
@click.argument("username")
|
|
72
|
+
@click.option("--email", default=None, help="User email address.")
|
|
73
|
+
@click.option("--full-name", default=None, help="User display name.")
|
|
74
|
+
@click.option(
|
|
75
|
+
"--role",
|
|
76
|
+
"roles",
|
|
77
|
+
multiple=True,
|
|
78
|
+
required=True,
|
|
79
|
+
help="Elasticsearch role. Repeat for multiple roles, e.g. --role kibana_admin.",
|
|
80
|
+
)
|
|
81
|
+
@click.option("--password", default=None, help="Initial password. Prompts if omitted.")
|
|
82
|
+
@click.option(
|
|
83
|
+
"--generate-password",
|
|
84
|
+
is_flag=True,
|
|
85
|
+
help="Generate and print a random initial password instead of prompting.",
|
|
86
|
+
)
|
|
87
|
+
@click.option("--disabled", is_flag=True, help="Create/update the user as disabled.")
|
|
88
|
+
@click.option(
|
|
89
|
+
"--metadata",
|
|
90
|
+
default=None,
|
|
91
|
+
help="Optional JSON object for Elasticsearch user metadata.",
|
|
92
|
+
)
|
|
93
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON result.")
|
|
94
|
+
@common_options
|
|
95
|
+
def add_user_cmd(
|
|
96
|
+
username: str,
|
|
97
|
+
email: str | None,
|
|
98
|
+
full_name: str | None,
|
|
99
|
+
roles: tuple[str, ...],
|
|
100
|
+
password: str | None,
|
|
101
|
+
generate_password: bool,
|
|
102
|
+
disabled: bool,
|
|
103
|
+
metadata: str | None,
|
|
104
|
+
as_json: bool,
|
|
105
|
+
url: str | None,
|
|
106
|
+
admin_username: str | None,
|
|
107
|
+
admin_password: str | None,
|
|
108
|
+
api_key: str | None,
|
|
109
|
+
verify_tls: bool,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Create or update an Elasticsearch native user for Kibana/ELK access."""
|
|
112
|
+
if password and generate_password:
|
|
113
|
+
raise click.ClickException("Use either --password or --generate-password, not both")
|
|
114
|
+
generated_password: str | None = None
|
|
115
|
+
if generate_password:
|
|
116
|
+
generated_password = generate_elk_password()
|
|
117
|
+
password = generated_password
|
|
118
|
+
elif password is None:
|
|
119
|
+
password = getpass.getpass(f"Initial password for {username}: ")
|
|
120
|
+
confirm = getpass.getpass("Confirm password: ")
|
|
121
|
+
if password != confirm:
|
|
122
|
+
raise click.ClickException("Passwords do not match")
|
|
123
|
+
|
|
124
|
+
parsed_metadata: dict[str, Any] | None = None
|
|
125
|
+
if metadata:
|
|
126
|
+
try:
|
|
127
|
+
raw_metadata = json.loads(metadata)
|
|
128
|
+
except json.JSONDecodeError as exc:
|
|
129
|
+
raise click.ClickException(f"Invalid --metadata JSON: {exc}") from exc
|
|
130
|
+
if not isinstance(raw_metadata, dict):
|
|
131
|
+
raise click.ClickException("--metadata must be a JSON object")
|
|
132
|
+
parsed_metadata = raw_metadata
|
|
133
|
+
|
|
134
|
+
client = _client(url, admin_username, admin_password, api_key, verify_tls)
|
|
135
|
+
try:
|
|
136
|
+
existed_before = client.get_user(username) is not None
|
|
137
|
+
result = client.put_user(
|
|
138
|
+
username,
|
|
139
|
+
password=password,
|
|
140
|
+
roles=roles,
|
|
141
|
+
full_name=full_name,
|
|
142
|
+
email=email,
|
|
143
|
+
enabled=not disabled,
|
|
144
|
+
metadata=parsed_metadata,
|
|
145
|
+
)
|
|
146
|
+
except ElasticsearchSecurityError as exc:
|
|
147
|
+
raise click.ClickException(str(exc)) from exc
|
|
148
|
+
|
|
149
|
+
out = {
|
|
150
|
+
"username": username,
|
|
151
|
+
"created": not existed_before,
|
|
152
|
+
"updated": existed_before,
|
|
153
|
+
"roles": list(roles),
|
|
154
|
+
"enabled": not disabled,
|
|
155
|
+
"result": result,
|
|
156
|
+
}
|
|
157
|
+
if generated_password is not None:
|
|
158
|
+
out["password"] = generated_password
|
|
159
|
+
if as_json:
|
|
160
|
+
click.echo(json.dumps(out, indent=2, default=str))
|
|
161
|
+
return
|
|
162
|
+
click.echo(f"{'Created' if not existed_before else 'Updated'} ELK user {username}")
|
|
163
|
+
click.echo(f"Roles: {', '.join(roles)}")
|
|
164
|
+
if generated_password is not None:
|
|
165
|
+
click.echo(f"Password: {generated_password}")
|
|
166
|
+
|
|
167
|
+
@elk.command("get-user")
|
|
168
|
+
@click.argument("username")
|
|
169
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit raw JSON result.")
|
|
170
|
+
@common_options
|
|
171
|
+
def get_user_cmd(
|
|
172
|
+
username: str,
|
|
173
|
+
as_json: bool,
|
|
174
|
+
url: str | None,
|
|
175
|
+
admin_username: str | None,
|
|
176
|
+
admin_password: str | None,
|
|
177
|
+
api_key: str | None,
|
|
178
|
+
verify_tls: bool,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Show an Elasticsearch native user."""
|
|
181
|
+
client = _client(url, admin_username, admin_password, api_key, verify_tls)
|
|
182
|
+
try:
|
|
183
|
+
user = client.get_user(username)
|
|
184
|
+
except ElasticsearchSecurityError as exc:
|
|
185
|
+
raise click.ClickException(str(exc)) from exc
|
|
186
|
+
if user is None:
|
|
187
|
+
click.echo(f"User {username!r} not found", err=True)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
if as_json:
|
|
190
|
+
click.echo(json.dumps(user, indent=2, default=str))
|
|
191
|
+
return
|
|
192
|
+
click.echo(f"{username} | roles={','.join(user.get('roles', []))} | enabled={user.get('enabled')}")
|
|
@@ -172,6 +172,10 @@ SECRET_MAP: dict[str, str] = {
|
|
|
172
172
|
# superuser-issued token (Authentik UI → Directory → Tokens). Falls back
|
|
173
173
|
# to AK_TOKEN env var in granny.authentik.AuthentikClient.from_environment.
|
|
174
174
|
"AUTHENTIK_API_TOKEN": "authentik-api-token",
|
|
175
|
+
# Elasticsearch / Kibana security API credentials for `granny elk …`.
|
|
176
|
+
"ELASTICSEARCH_API_KEY": "elasticsearch-api-key",
|
|
177
|
+
"ELASTICSEARCH_USERNAME": "elasticsearch-username",
|
|
178
|
+
"ELASTICSEARCH_PASSWORD": "elasticsearch-password",
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
# Module-level cache so vault is only contacted once per process
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Thin wrapper around Elasticsearch's security user API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import string
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from granny.credentials import get_secret
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ElasticsearchSecurityError(RuntimeError):
|
|
16
|
+
"""Raised on non-2xx responses from Elasticsearch security APIs."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status: int, body: str) -> None:
|
|
19
|
+
super().__init__(f"HTTP {status}: {body[:400]}")
|
|
20
|
+
self.status = status
|
|
21
|
+
self.body = body
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ElasticsearchSecurityClient:
|
|
25
|
+
"""Authenticated client for Elasticsearch native-user management."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
*,
|
|
31
|
+
username: str | None = None,
|
|
32
|
+
password: str | None = None,
|
|
33
|
+
api_key: str | None = None,
|
|
34
|
+
timeout: float = 30.0,
|
|
35
|
+
verify_tls: bool = True,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._session = requests.Session()
|
|
40
|
+
self._session.headers.update({"Accept": "application/json"})
|
|
41
|
+
self._session.verify = verify_tls
|
|
42
|
+
if api_key:
|
|
43
|
+
self._session.headers["Authorization"] = f"ApiKey {api_key}"
|
|
44
|
+
elif username and password:
|
|
45
|
+
self._session.auth = (username, password)
|
|
46
|
+
else:
|
|
47
|
+
raise ElasticsearchSecurityError(
|
|
48
|
+
0,
|
|
49
|
+
"Set ELASTICSEARCH_API_KEY or ELASTICSEARCH_USERNAME + "
|
|
50
|
+
"ELASTICSEARCH_PASSWORD.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_environment(
|
|
55
|
+
cls,
|
|
56
|
+
*,
|
|
57
|
+
base_url: str | None = None,
|
|
58
|
+
username: str | None = None,
|
|
59
|
+
password: str | None = None,
|
|
60
|
+
api_key: str | None = None,
|
|
61
|
+
verify_tls: bool | None = None,
|
|
62
|
+
) -> "ElasticsearchSecurityClient":
|
|
63
|
+
"""Build a client from explicit arguments, env vars, and vault fallback."""
|
|
64
|
+
resolved_url = base_url or os.environ.get("ELASTICSEARCH_URL")
|
|
65
|
+
if not resolved_url:
|
|
66
|
+
raise ElasticsearchSecurityError(0, "ELASTICSEARCH_URL is not set")
|
|
67
|
+
|
|
68
|
+
resolved_api_key = api_key or get_secret("ELASTICSEARCH_API_KEY")
|
|
69
|
+
resolved_username = None
|
|
70
|
+
resolved_password = None
|
|
71
|
+
if not resolved_api_key:
|
|
72
|
+
resolved_username = username or get_secret("ELASTICSEARCH_USERNAME")
|
|
73
|
+
resolved_password = password or get_secret("ELASTICSEARCH_PASSWORD")
|
|
74
|
+
if verify_tls is None:
|
|
75
|
+
verify_tls = os.environ.get("ELASTICSEARCH_VERIFY_TLS", "true").lower() not in {
|
|
76
|
+
"0",
|
|
77
|
+
"false",
|
|
78
|
+
"no",
|
|
79
|
+
}
|
|
80
|
+
return cls(
|
|
81
|
+
resolved_url,
|
|
82
|
+
username=resolved_username,
|
|
83
|
+
password=resolved_password,
|
|
84
|
+
api_key=resolved_api_key,
|
|
85
|
+
verify_tls=verify_tls,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def request(self, method: str, path: str, *, body: Any = None) -> Any:
|
|
89
|
+
if not path.startswith("/"):
|
|
90
|
+
path = "/" + path
|
|
91
|
+
try:
|
|
92
|
+
resp = self._session.request(
|
|
93
|
+
method.upper(),
|
|
94
|
+
self.base_url + path,
|
|
95
|
+
json=body,
|
|
96
|
+
timeout=self._timeout,
|
|
97
|
+
)
|
|
98
|
+
except requests.RequestException as exc:
|
|
99
|
+
raise ElasticsearchSecurityError(0, str(exc)) from exc
|
|
100
|
+
if resp.status_code >= 400:
|
|
101
|
+
raise ElasticsearchSecurityError(resp.status_code, resp.text)
|
|
102
|
+
if not resp.content:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return resp.json()
|
|
106
|
+
except ValueError:
|
|
107
|
+
return resp.text
|
|
108
|
+
|
|
109
|
+
def get_user(self, username: str) -> dict[str, Any] | None:
|
|
110
|
+
try:
|
|
111
|
+
result = self.request("GET", f"/_security/user/{username}")
|
|
112
|
+
except ElasticsearchSecurityError as exc:
|
|
113
|
+
if exc.status == 404:
|
|
114
|
+
return None
|
|
115
|
+
raise
|
|
116
|
+
if isinstance(result, dict):
|
|
117
|
+
return result.get(username)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def put_user(
|
|
121
|
+
self,
|
|
122
|
+
username: str,
|
|
123
|
+
*,
|
|
124
|
+
roles: list[str] | tuple[str, ...],
|
|
125
|
+
password: str | None = None,
|
|
126
|
+
full_name: str | None = None,
|
|
127
|
+
email: str | None = None,
|
|
128
|
+
enabled: bool = True,
|
|
129
|
+
metadata: dict[str, Any] | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Create or update a native Elasticsearch user."""
|
|
132
|
+
body: dict[str, Any] = {
|
|
133
|
+
"roles": list(roles),
|
|
134
|
+
"enabled": enabled,
|
|
135
|
+
"metadata": metadata or {},
|
|
136
|
+
}
|
|
137
|
+
if password is not None:
|
|
138
|
+
body["password"] = password
|
|
139
|
+
if full_name is not None:
|
|
140
|
+
body["full_name"] = full_name
|
|
141
|
+
if email is not None:
|
|
142
|
+
body["email"] = email
|
|
143
|
+
return self.request("PUT", f"/_security/user/{username}", body=body)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def generate_password(length: int = 24) -> str:
|
|
147
|
+
"""Generate a Kibana-friendly random password."""
|
|
148
|
+
alphabet = string.ascii_letters + string.digits + "-_.!~"
|
|
149
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|