granny-devops 0.9.1__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.1 → granny_devops-0.9.3}/PKG-INFO +15 -1
- {granny_devops-0.9.1 → granny_devops-0.9.3}/README.md +14 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/__init__.py +1 -1
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/authentik/__init__.py +4 -0
- granny_devops-0.9.3/granny/authentik/provision.py +197 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/authentik.py +105 -1
- granny_devops-0.9.3/granny/cli/elk.py +192 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/main.py +5 -0
- {granny_devops-0.9.1 → 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.1 → granny_devops-0.9.3}/pyproject.toml +1 -1
- {granny_devops-0.9.1 → granny_devops-0.9.3}/.gitignore +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/LICENSE +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/costs.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/credits.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/authentik/client.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/analyze.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/cdn.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/create.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/credentials.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/dns.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/docker.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/edge.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/email.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/serverless.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/storage.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/registrars.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/base.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/bunny.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/desec.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/factory.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/inwx.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/manual.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/records.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/docker/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/docker/build_base.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/edge/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/edge/bunny.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/mailjet.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/workmail.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/report.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/storage/__init__.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/storage/aws.py +0 -0
- {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/storage/bunny.py +0 -0
- {granny_devops-0.9.1 → 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.
|
|
@@ -288,10 +289,22 @@ granny create scaleway-container --name my-app --port 3000
|
|
|
288
289
|
granny create mailjet-dns example.com
|
|
289
290
|
|
|
290
291
|
# Authentik admin (provider + application + group plumbing)
|
|
292
|
+
granny authentik provision-oauth-app my-app \
|
|
293
|
+
--name "My App" \
|
|
294
|
+
--redirect-uri https://app.example.com/auth/callback \
|
|
295
|
+
--launch-url https://app.example.com \
|
|
296
|
+
--group my-app-admins
|
|
291
297
|
granny authentik list providers
|
|
292
298
|
granny authentik rotate-secret my-oauth-provider
|
|
293
299
|
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
294
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
|
|
295
308
|
```
|
|
296
309
|
|
|
297
310
|
## Capability matrix
|
|
@@ -309,6 +322,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
309
322
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
310
323
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
311
324
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
325
|
+
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
312
326
|
|
|
313
327
|
## As a library
|
|
314
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.
|
|
@@ -156,10 +157,22 @@ granny create scaleway-container --name my-app --port 3000
|
|
|
156
157
|
granny create mailjet-dns example.com
|
|
157
158
|
|
|
158
159
|
# Authentik admin (provider + application + group plumbing)
|
|
160
|
+
granny authentik provision-oauth-app my-app \
|
|
161
|
+
--name "My App" \
|
|
162
|
+
--redirect-uri https://app.example.com/auth/callback \
|
|
163
|
+
--launch-url https://app.example.com \
|
|
164
|
+
--group my-app-admins
|
|
159
165
|
granny authentik list providers
|
|
160
166
|
granny authentik rotate-secret my-oauth-provider
|
|
161
167
|
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
162
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
|
|
163
176
|
```
|
|
164
177
|
|
|
165
178
|
## Capability matrix
|
|
@@ -177,6 +190,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
177
190
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
178
191
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
179
192
|
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
193
|
+
| Observability admin | Elasticsearch / Kibana native-user management |
|
|
180
194
|
|
|
181
195
|
## As a library
|
|
182
196
|
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Authentik management — REST API wrapper."""
|
|
2
2
|
|
|
3
3
|
from granny.authentik.client import AuthentikClient, AuthentikError
|
|
4
|
+
from granny.authentik.provision import OAuthAppSpec, ProvisionResult, provision_oauth_app
|
|
4
5
|
|
|
5
6
|
__all__ = [
|
|
6
7
|
"AuthentikClient",
|
|
7
8
|
"AuthentikError",
|
|
9
|
+
"OAuthAppSpec",
|
|
10
|
+
"ProvisionResult",
|
|
11
|
+
"provision_oauth_app",
|
|
8
12
|
]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Generic Authentik OAuth2 application provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from granny.authentik.client import AuthentikClient
|
|
8
|
+
|
|
9
|
+
DEFAULT_AUTHORIZATION_FLOW_SLUG = "default-provider-authorization-explicit-consent"
|
|
10
|
+
DEFAULT_INVALIDATION_FLOW_SLUG = "default-provider-invalidation-flow"
|
|
11
|
+
DEFAULT_OIDC_SCOPES: tuple[str, ...] = ("openid", "profile", "email")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class OAuthAppSpec:
|
|
16
|
+
"""Desired state for an Authentik OAuth2 provider + application."""
|
|
17
|
+
|
|
18
|
+
slug: str
|
|
19
|
+
name: str
|
|
20
|
+
redirect_uris: tuple[str, ...]
|
|
21
|
+
launch_url: str | None = None
|
|
22
|
+
group: str | None = None
|
|
23
|
+
provider_name: str | None = None
|
|
24
|
+
scopes: tuple[str, ...] = DEFAULT_OIDC_SCOPES
|
|
25
|
+
authorization_flow_slug: str = DEFAULT_AUTHORIZATION_FLOW_SLUG
|
|
26
|
+
invalidation_flow_slug: str = DEFAULT_INVALIDATION_FLOW_SLUG
|
|
27
|
+
access_token_validity: str = "minutes=10"
|
|
28
|
+
refresh_token_validity: str = "days=30"
|
|
29
|
+
sub_mode: str = "user_username"
|
|
30
|
+
include_claims_in_id_token: bool = True
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not self.slug:
|
|
34
|
+
raise ValueError("slug must not be empty")
|
|
35
|
+
if not self.name:
|
|
36
|
+
raise ValueError("name must not be empty")
|
|
37
|
+
if not self.redirect_uris:
|
|
38
|
+
raise ValueError("at least one redirect URI is required")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ProvisionResult:
|
|
43
|
+
"""Result of provisioning an Authentik OAuth2 application."""
|
|
44
|
+
|
|
45
|
+
base_url: str
|
|
46
|
+
issuer: str
|
|
47
|
+
client_id: str
|
|
48
|
+
client_secret: str
|
|
49
|
+
redirect_uris: tuple[str, ...]
|
|
50
|
+
scopes: str
|
|
51
|
+
provider_pk: str
|
|
52
|
+
application_slug: str
|
|
53
|
+
group: str | None
|
|
54
|
+
group_pk: str | None
|
|
55
|
+
created_provider: bool
|
|
56
|
+
created_application: bool
|
|
57
|
+
created_binding: bool
|
|
58
|
+
created_group: bool
|
|
59
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def authorization_endpoint(self) -> str:
|
|
63
|
+
return f"{self.base_url}/application/o/authorize/"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def token_endpoint(self) -> str:
|
|
67
|
+
return f"{self.base_url}/application/o/token/"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def userinfo_endpoint(self) -> str:
|
|
71
|
+
return f"{self.base_url}/application/o/userinfo/"
|
|
72
|
+
|
|
73
|
+
def oidc_block(self) -> dict[str, str | list[str] | None]:
|
|
74
|
+
"""Return a copy-paste friendly OIDC configuration snippet."""
|
|
75
|
+
return {
|
|
76
|
+
"issuer": self.issuer,
|
|
77
|
+
"authorization_endpoint": self.authorization_endpoint,
|
|
78
|
+
"token_endpoint": self.token_endpoint,
|
|
79
|
+
"userinfo_endpoint": self.userinfo_endpoint,
|
|
80
|
+
"client_id": self.client_id,
|
|
81
|
+
"client_secret": self.client_secret or None,
|
|
82
|
+
"redirect_uris": list(self.redirect_uris),
|
|
83
|
+
"scopes": self.scopes,
|
|
84
|
+
"admin_group": self.group,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def provision_oauth_app(
|
|
89
|
+
client: AuthentikClient,
|
|
90
|
+
spec: OAuthAppSpec,
|
|
91
|
+
*,
|
|
92
|
+
rotate_secret: bool = False,
|
|
93
|
+
) -> ProvisionResult:
|
|
94
|
+
"""Idempotently provision an OAuth2 provider and application in Authentik."""
|
|
95
|
+
provider_name = spec.provider_name or spec.slug
|
|
96
|
+
authorization_flow = client.find_flow_pk(spec.authorization_flow_slug)
|
|
97
|
+
invalidation_flow = client.find_flow_pk(spec.invalidation_flow_slug)
|
|
98
|
+
signing_key = client.find_signing_key_pk()
|
|
99
|
+
scope_mappings = client.find_scope_mappings(spec.scopes)
|
|
100
|
+
|
|
101
|
+
group = None
|
|
102
|
+
created_group = False
|
|
103
|
+
if spec.group:
|
|
104
|
+
existing_group = client.find_group_by_name(spec.group)
|
|
105
|
+
created_group = existing_group is None
|
|
106
|
+
group = existing_group or client.ensure_group(spec.group)
|
|
107
|
+
|
|
108
|
+
existing_app = client.find_application_by_slug(spec.slug)
|
|
109
|
+
adopt_pk: int | str | None = None
|
|
110
|
+
if existing_app is not None:
|
|
111
|
+
adopt_pk = existing_app.get("provider")
|
|
112
|
+
existing_provider = (
|
|
113
|
+
client.find_provider_by_pk(adopt_pk) if adopt_pk is not None else None
|
|
114
|
+
) or client.find_provider_by_name(provider_name)
|
|
115
|
+
|
|
116
|
+
provider_body: dict[str, object] = {
|
|
117
|
+
"name": provider_name,
|
|
118
|
+
"authorization_flow": authorization_flow,
|
|
119
|
+
"invalidation_flow": invalidation_flow,
|
|
120
|
+
"client_type": "confidential",
|
|
121
|
+
"redirect_uris": [
|
|
122
|
+
{"matching_mode": "strict", "url": uri} for uri in spec.redirect_uris
|
|
123
|
+
],
|
|
124
|
+
"signing_key": signing_key,
|
|
125
|
+
"property_mappings": scope_mappings,
|
|
126
|
+
"sub_mode": spec.sub_mode,
|
|
127
|
+
"include_claims_in_id_token": spec.include_claims_in_id_token,
|
|
128
|
+
"access_token_validity": spec.access_token_validity,
|
|
129
|
+
"refresh_token_validity": spec.refresh_token_validity,
|
|
130
|
+
}
|
|
131
|
+
if existing_provider:
|
|
132
|
+
if rotate_secret:
|
|
133
|
+
provider_body["client_secret"] = ""
|
|
134
|
+
provider = client.request(
|
|
135
|
+
"PATCH",
|
|
136
|
+
f"/api/v3/providers/oauth2/{existing_provider['pk']}/",
|
|
137
|
+
body=provider_body,
|
|
138
|
+
)
|
|
139
|
+
created_provider = False
|
|
140
|
+
else:
|
|
141
|
+
provider = client.request("POST", "/api/v3/providers/oauth2/", body=provider_body)
|
|
142
|
+
created_provider = True
|
|
143
|
+
|
|
144
|
+
app_body: dict[str, object] = {
|
|
145
|
+
"name": spec.name,
|
|
146
|
+
"slug": spec.slug,
|
|
147
|
+
"provider": provider["pk"],
|
|
148
|
+
"policy_engine_mode": "any",
|
|
149
|
+
}
|
|
150
|
+
if spec.launch_url is not None:
|
|
151
|
+
app_body["meta_launch_url"] = spec.launch_url
|
|
152
|
+
if existing_app:
|
|
153
|
+
client.request("PATCH", f"/api/v3/core/applications/{spec.slug}/", body=app_body)
|
|
154
|
+
created_application = False
|
|
155
|
+
target_pk = existing_app["pk"]
|
|
156
|
+
else:
|
|
157
|
+
new_app = client.request("POST", "/api/v3/core/applications/", body=app_body)
|
|
158
|
+
created_application = True
|
|
159
|
+
target_pk = new_app["pk"]
|
|
160
|
+
|
|
161
|
+
created_binding = False
|
|
162
|
+
if group is not None:
|
|
163
|
+
bindings = client.request(
|
|
164
|
+
"GET", "/api/v3/policies/bindings/", params={"target": target_pk}
|
|
165
|
+
).get("results", [])
|
|
166
|
+
has_binding = any(binding.get("group") == group["pk"] for binding in bindings)
|
|
167
|
+
if not has_binding:
|
|
168
|
+
client.request(
|
|
169
|
+
"POST",
|
|
170
|
+
"/api/v3/policies/bindings/",
|
|
171
|
+
body={
|
|
172
|
+
"target": target_pk,
|
|
173
|
+
"group": group["pk"],
|
|
174
|
+
"enabled": True,
|
|
175
|
+
"order": 0,
|
|
176
|
+
"negate": False,
|
|
177
|
+
"timeout": 30,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
created_binding = True
|
|
181
|
+
|
|
182
|
+
return ProvisionResult(
|
|
183
|
+
base_url=client.base_url,
|
|
184
|
+
issuer=f"{client.base_url}/application/o/{spec.slug}",
|
|
185
|
+
client_id=provider["client_id"],
|
|
186
|
+
client_secret=provider.get("client_secret") or "",
|
|
187
|
+
redirect_uris=spec.redirect_uris,
|
|
188
|
+
scopes=" ".join(spec.scopes),
|
|
189
|
+
provider_pk=str(provider["pk"]),
|
|
190
|
+
application_slug=spec.slug,
|
|
191
|
+
group=spec.group,
|
|
192
|
+
group_pk=str(group["pk"]) if group is not None else None,
|
|
193
|
+
created_provider=created_provider,
|
|
194
|
+
created_application=created_application,
|
|
195
|
+
created_binding=created_binding,
|
|
196
|
+
created_group=created_group,
|
|
197
|
+
)
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
import click
|
|
14
14
|
|
|
15
|
-
from granny.authentik import AuthentikClient, AuthentikError
|
|
15
|
+
from granny.authentik import AuthentikClient, AuthentikError, OAuthAppSpec, provision_oauth_app
|
|
16
16
|
|
|
17
17
|
ADMIN_GROUP_NAME = "dash_admins"
|
|
18
18
|
|
|
@@ -30,6 +30,110 @@ def _client() -> AuthentikClient:
|
|
|
30
30
|
sys.exit(1)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _print_provision_report(result: Any) -> None:
|
|
34
|
+
click.echo("", err=True)
|
|
35
|
+
click.echo("─" * 72)
|
|
36
|
+
click.echo(f"Authentik URL : {result.base_url}")
|
|
37
|
+
click.echo(f"Application slug : {result.application_slug}")
|
|
38
|
+
click.echo(f"Issuer : {result.issuer}")
|
|
39
|
+
click.echo(f"client_id : {result.client_id}")
|
|
40
|
+
click.echo(
|
|
41
|
+
f"client_secret : {result.client_secret or '(not echoed — rotate-secret to see)'}"
|
|
42
|
+
)
|
|
43
|
+
click.echo(f"redirect_uris : {', '.join(result.redirect_uris)}")
|
|
44
|
+
click.echo(f"group : {result.group or '(none)'}")
|
|
45
|
+
click.echo(f"scopes : {result.scopes}")
|
|
46
|
+
flags = [
|
|
47
|
+
f"provider={'NEW' if result.created_provider else 'EXISTS'}",
|
|
48
|
+
f"application={'NEW' if result.created_application else 'EXISTS'}",
|
|
49
|
+
f"binding={'NEW' if result.created_binding else 'EXISTS'}",
|
|
50
|
+
f"group={'NEW' if result.created_group else 'EXISTS'}",
|
|
51
|
+
]
|
|
52
|
+
click.echo(f"state : {', '.join(flags)}")
|
|
53
|
+
click.echo("─" * 72)
|
|
54
|
+
click.echo("")
|
|
55
|
+
click.echo(json.dumps({"oidc": result.oidc_block()}, indent=2))
|
|
56
|
+
if not result.client_secret:
|
|
57
|
+
click.echo("", err=True)
|
|
58
|
+
click.echo(
|
|
59
|
+
"WARN: Authentik did not echo client_secret in the response. "
|
|
60
|
+
"Run `granny authentik rotate-secret " + result.application_slug
|
|
61
|
+
+ "` to mint a fresh secret you can copy.",
|
|
62
|
+
err=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@authentik.command("provision-oauth-app")
|
|
67
|
+
@click.argument("slug")
|
|
68
|
+
@click.option("--name", required=True, help="Application display name.")
|
|
69
|
+
@click.option(
|
|
70
|
+
"--redirect-uri",
|
|
71
|
+
"redirect_uris",
|
|
72
|
+
multiple=True,
|
|
73
|
+
required=True,
|
|
74
|
+
help="Allowed redirect URI. Repeat for multiple callbacks.",
|
|
75
|
+
)
|
|
76
|
+
@click.option("--launch-url", default=None, help="Optional application launch URL.")
|
|
77
|
+
@click.option("--group", default=None, help="Optional group allowed to access the app.")
|
|
78
|
+
@click.option("--provider-name", default=None, help="OAuth2 provider name (defaults to slug).")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--scope",
|
|
81
|
+
"scopes",
|
|
82
|
+
multiple=True,
|
|
83
|
+
default=("openid", "profile", "email"),
|
|
84
|
+
show_default=True,
|
|
85
|
+
help="OIDC scope to include. Repeat to override/add scopes.",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--authorization-flow",
|
|
89
|
+
default="default-provider-authorization-explicit-consent",
|
|
90
|
+
show_default=True,
|
|
91
|
+
help="Authorization flow slug.",
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--invalidation-flow",
|
|
95
|
+
default="default-provider-invalidation-flow",
|
|
96
|
+
show_default=True,
|
|
97
|
+
help="Invalidation flow slug.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--rotate",
|
|
101
|
+
is_flag=True,
|
|
102
|
+
help="Force a fresh client_secret if the provider already exists.",
|
|
103
|
+
)
|
|
104
|
+
def provision_oauth_app_cmd(
|
|
105
|
+
slug: str,
|
|
106
|
+
name: str,
|
|
107
|
+
redirect_uris: tuple[str, ...],
|
|
108
|
+
launch_url: str | None,
|
|
109
|
+
group: str | None,
|
|
110
|
+
provider_name: str | None,
|
|
111
|
+
scopes: tuple[str, ...],
|
|
112
|
+
authorization_flow: str,
|
|
113
|
+
invalidation_flow: str,
|
|
114
|
+
rotate: bool,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Provision an OAuth2 provider + application + optional group binding."""
|
|
117
|
+
spec = OAuthAppSpec(
|
|
118
|
+
slug=slug,
|
|
119
|
+
name=name,
|
|
120
|
+
redirect_uris=redirect_uris,
|
|
121
|
+
launch_url=launch_url,
|
|
122
|
+
group=group,
|
|
123
|
+
provider_name=provider_name,
|
|
124
|
+
scopes=scopes,
|
|
125
|
+
authorization_flow_slug=authorization_flow,
|
|
126
|
+
invalidation_flow_slug=invalidation_flow,
|
|
127
|
+
)
|
|
128
|
+
client = _client()
|
|
129
|
+
try:
|
|
130
|
+
result = provision_oauth_app(client, spec, rotate_secret=rotate)
|
|
131
|
+
except AuthentikError as exc:
|
|
132
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
_print_provision_report(result)
|
|
135
|
+
|
|
136
|
+
|
|
33
137
|
_LIST_ENDPOINTS = {
|
|
34
138
|
"providers": "/api/v3/providers/oauth2/",
|
|
35
139
|
"applications": "/api/v3/core/applications/",
|
|
@@ -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
|