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.
Files changed (85) hide show
  1. {granny_devops-0.9.1 → granny_devops-0.9.3}/PKG-INFO +15 -1
  2. {granny_devops-0.9.1 → granny_devops-0.9.3}/README.md +14 -0
  3. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/__init__.py +1 -1
  4. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/authentik/__init__.py +4 -0
  5. granny_devops-0.9.3/granny/authentik/provision.py +197 -0
  6. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/authentik.py +105 -1
  7. granny_devops-0.9.3/granny/cli/elk.py +192 -0
  8. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/main.py +5 -0
  9. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/credentials/secrets.py +4 -0
  10. granny_devops-0.9.3/granny/elk/__init__.py +5 -0
  11. granny_devops-0.9.3/granny/elk/client.py +149 -0
  12. {granny_devops-0.9.1 → granny_devops-0.9.3}/pyproject.toml +1 -1
  13. {granny_devops-0.9.1 → granny_devops-0.9.3}/.gitignore +0 -0
  14. {granny_devops-0.9.1 → granny_devops-0.9.3}/LICENSE +0 -0
  15. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/__init__.py +0 -0
  16. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/costs.py +0 -0
  17. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/credits.py +0 -0
  18. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/gpus.py +0 -0
  19. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/lambdas.py +0 -0
  20. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/analyze/vpcs.py +0 -0
  21. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/authentik/client.py +0 -0
  22. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cdn/__init__.py +0 -0
  23. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cdn/bunny.py +0 -0
  24. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/__init__.py +0 -0
  25. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/analyze.py +0 -0
  26. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/cdn.py +0 -0
  27. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/cloudflare.py +0 -0
  28. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/create.py +0 -0
  29. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/credentials.py +0 -0
  30. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/dns.py +0 -0
  31. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/docker.py +0 -0
  32. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/edge.py +0 -0
  33. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/email.py +0 -0
  34. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/serverless.py +0 -0
  35. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cli/storage.py +0 -0
  36. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/__init__.py +0 -0
  37. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/d1.py +0 -0
  38. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/r2.py +0 -0
  39. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/cloudflare/workers.py +0 -0
  40. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/__init__.py +0 -0
  41. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/auto_certificate.py +0 -0
  42. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/cloudfront-security-headers.js +0 -0
  43. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/manage-dns.sh +0 -0
  44. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/manage_mailjet_contacts.py +0 -0
  45. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/registrars.py +0 -0
  46. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_aws_cloudfront.py +0 -0
  47. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_bunny_edge_script.py +0 -0
  48. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_bunny_storage.py +0 -0
  49. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_cognito_identity_pool.py +0 -0
  50. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_hetzner_bunny.py +0 -0
  51. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_mailjet_dns.py +0 -0
  52. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_private_cdn.py +0 -0
  53. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_s3_website.py +0 -0
  54. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_scaleway_container.py +0 -0
  55. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_scaleway_faas.py +0 -0
  56. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/setup_workmail.py +0 -0
  57. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/create/www-redirect-function.js +0 -0
  58. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/credentials/__init__.py +0 -0
  59. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/__init__.py +0 -0
  60. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/base.py +0 -0
  61. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/bunny.py +0 -0
  62. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/cloudflare.py +0 -0
  63. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/cloudns.py +0 -0
  64. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/desec.py +0 -0
  65. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/factory.py +0 -0
  66. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/hetzner.py +0 -0
  67. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/inwx.py +0 -0
  68. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/manual.py +0 -0
  69. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/dns/records.py +0 -0
  70. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/docker/__init__.py +0 -0
  71. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/docker/build_base.py +0 -0
  72. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/edge/__init__.py +0 -0
  73. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/edge/bunny.py +0 -0
  74. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/__init__.py +0 -0
  75. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/mailjet.py +0 -0
  76. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/mailjet_contacts.py +0 -0
  77. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/ses_forwarding.py +0 -0
  78. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/email/workmail.py +0 -0
  79. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/report.py +0 -0
  80. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/serverless/__init__.py +0 -0
  81. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/serverless/scaleway.py +0 -0
  82. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/storage/__init__.py +0 -0
  83. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/storage/aws.py +0 -0
  84. {granny_devops-0.9.1 → granny_devops-0.9.3}/granny/storage/bunny.py +0 -0
  85. {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.1
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,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.9.1"
3
+ __version__ = "0.9.3"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -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')}")
@@ -100,6 +100,11 @@ def _register_commands() -> None:
100
100
  cli.add_command(authentik)
101
101
  except ImportError:
102
102
  pass
103
+ try:
104
+ from granny.cli.elk import elk
105
+ cli.add_command(elk)
106
+ except ImportError:
107
+ pass
103
108
 
104
109
 
105
110
  _register_commands()
@@ -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,5 @@
1
+ """Elasticsearch / Kibana user management helpers."""
2
+
3
+ from granny.elk.client import ElasticsearchSecurityClient, ElasticsearchSecurityError
4
+
5
+ __all__ = ["ElasticsearchSecurityClient", "ElasticsearchSecurityError"]
@@ -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))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.9.1"
3
+ version = "0.9.3"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
File without changes
File without changes