granny-devops 0.8.0__tar.gz → 0.9.1__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 (81) hide show
  1. {granny_devops-0.8.0 → granny_devops-0.9.1}/PKG-INFO +8 -1
  2. {granny_devops-0.8.0 → granny_devops-0.9.1}/README.md +7 -0
  3. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/__init__.py +1 -1
  4. granny_devops-0.9.1/granny/authentik/__init__.py +8 -0
  5. granny_devops-0.9.1/granny/authentik/client.py +337 -0
  6. granny_devops-0.9.1/granny/cli/authentik.py +263 -0
  7. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/main.py +5 -0
  8. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/serverless.py +3 -3
  9. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/credentials/secrets.py +4 -0
  10. {granny_devops-0.8.0 → granny_devops-0.9.1}/pyproject.toml +127 -127
  11. {granny_devops-0.8.0 → granny_devops-0.9.1}/.gitignore +0 -0
  12. {granny_devops-0.8.0 → granny_devops-0.9.1}/LICENSE +0 -0
  13. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/__init__.py +0 -0
  14. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/costs.py +0 -0
  15. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/credits.py +0 -0
  16. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/gpus.py +0 -0
  17. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/lambdas.py +0 -0
  18. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/analyze/vpcs.py +0 -0
  19. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cdn/__init__.py +0 -0
  20. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cdn/bunny.py +0 -0
  21. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/__init__.py +0 -0
  22. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/analyze.py +0 -0
  23. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/cdn.py +0 -0
  24. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/cloudflare.py +0 -0
  25. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/create.py +0 -0
  26. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/credentials.py +0 -0
  27. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/dns.py +0 -0
  28. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/docker.py +0 -0
  29. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/edge.py +0 -0
  30. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/email.py +0 -0
  31. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cli/storage.py +0 -0
  32. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/__init__.py +0 -0
  33. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/d1.py +0 -0
  34. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/r2.py +0 -0
  35. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/cloudflare/workers.py +0 -0
  36. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/__init__.py +0 -0
  37. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/auto_certificate.py +0 -0
  38. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/cloudfront-security-headers.js +0 -0
  39. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/manage-dns.sh +0 -0
  40. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/manage_mailjet_contacts.py +0 -0
  41. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/registrars.py +0 -0
  42. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_aws_cloudfront.py +0 -0
  43. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_bunny_edge_script.py +0 -0
  44. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_bunny_storage.py +0 -0
  45. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_cognito_identity_pool.py +0 -0
  46. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_hetzner_bunny.py +0 -0
  47. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_mailjet_dns.py +0 -0
  48. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_private_cdn.py +0 -0
  49. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_s3_website.py +0 -0
  50. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_container.py +0 -0
  51. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_faas.py +0 -0
  52. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/setup_workmail.py +0 -0
  53. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/create/www-redirect-function.js +0 -0
  54. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/credentials/__init__.py +0 -0
  55. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/__init__.py +0 -0
  56. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/base.py +0 -0
  57. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/bunny.py +0 -0
  58. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/cloudflare.py +0 -0
  59. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/cloudns.py +0 -0
  60. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/desec.py +0 -0
  61. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/factory.py +0 -0
  62. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/hetzner.py +0 -0
  63. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/inwx.py +0 -0
  64. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/manual.py +0 -0
  65. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/dns/records.py +0 -0
  66. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/docker/__init__.py +0 -0
  67. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/docker/build_base.py +0 -0
  68. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/edge/__init__.py +0 -0
  69. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/edge/bunny.py +0 -0
  70. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/__init__.py +0 -0
  71. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/mailjet.py +0 -0
  72. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/mailjet_contacts.py +0 -0
  73. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/ses_forwarding.py +0 -0
  74. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/email/workmail.py +0 -0
  75. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/report.py +0 -0
  76. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/serverless/__init__.py +0 -0
  77. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/serverless/scaleway.py +0 -0
  78. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/__init__.py +0 -0
  79. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/aws.py +0 -0
  80. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/bunny.py +0 -0
  81. {granny_devops-0.8.0 → granny_devops-0.9.1}/granny/storage/hetzner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.8.0
3
+ Version: 0.9.1
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
@@ -286,6 +286,12 @@ granny email workmail create-user example.com --email user@example.com
286
286
  granny create s3-website example.com --help
287
287
  granny create scaleway-container --name my-app --port 3000
288
288
  granny create mailjet-dns example.com
289
+
290
+ # Authentik admin (provider + application + group plumbing)
291
+ granny authentik list providers
292
+ granny authentik rotate-secret my-oauth-provider
293
+ granny authentik add-user-to-group user@example.com # defaults to dash_admins
294
+ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
289
295
  ```
290
296
 
291
297
  ## Capability matrix
@@ -302,6 +308,7 @@ granny create mailjet-dns example.com
302
308
  | AWS inventory | VPCs, Lambdas |
303
309
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
304
310
  | SSL automation | Bunny, Cloudflare, ACM |
311
+ | SSO / IdP | Authentik (provider, application, group, and user operations) |
305
312
 
306
313
  ## As a library
307
314
 
@@ -154,6 +154,12 @@ granny email workmail create-user example.com --email user@example.com
154
154
  granny create s3-website example.com --help
155
155
  granny create scaleway-container --name my-app --port 3000
156
156
  granny create mailjet-dns example.com
157
+
158
+ # Authentik admin (provider + application + group plumbing)
159
+ granny authentik list providers
160
+ granny authentik rotate-secret my-oauth-provider
161
+ granny authentik add-user-to-group user@example.com # defaults to dash_admins
162
+ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
157
163
  ```
158
164
 
159
165
  ## Capability matrix
@@ -170,6 +176,7 @@ granny create mailjet-dns example.com
170
176
  | AWS inventory | VPCs, Lambdas |
171
177
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
172
178
  | SSL automation | Bunny, Cloudflare, ACM |
179
+ | SSO / IdP | Authentik (provider, application, group, and user operations) |
173
180
 
174
181
  ## As a library
175
182
 
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.1"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,8 @@
1
+ """Authentik management — REST API wrapper."""
2
+
3
+ from granny.authentik.client import AuthentikClient, AuthentikError
4
+
5
+ __all__ = [
6
+ "AuthentikClient",
7
+ "AuthentikError",
8
+ ]
@@ -0,0 +1,337 @@
1
+ """Thin wrapper around the Authentik REST API.
2
+
3
+ Uses ``requests`` (already a granny core dependency) so this module does
4
+ not require any optional extras.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AuthentikError(RuntimeError):
18
+ """Raised on non-2xx responses from the Authentik API."""
19
+
20
+ def __init__(self, status: int, body: str) -> None:
21
+ super().__init__(f"HTTP {status}: {body[:400]}")
22
+ self.status = status
23
+ self.body = body
24
+
25
+
26
+ class AuthentikClient:
27
+ """Authenticated client for the Authentik v3 REST API.
28
+
29
+ Construct directly when you already have the credentials, or use
30
+ :meth:`from_environment` to resolve via granny's normal env/vault
31
+ pipeline.
32
+ """
33
+
34
+ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0) -> None:
35
+ self.base_url = base_url.rstrip("/")
36
+ self._token = token
37
+ self._timeout = timeout
38
+ self._session = requests.Session()
39
+ self._session.headers.update(
40
+ {
41
+ "Authorization": f"Bearer {token}",
42
+ "Accept": "application/json",
43
+ }
44
+ )
45
+
46
+ @classmethod
47
+ def from_environment(cls) -> "AuthentikClient":
48
+ """Build a client from env vars + Vaultwarden fallback.
49
+
50
+ Resolution order for the token (explicit env always wins so a
51
+ rotated value in ``.env`` is picked up immediately without having
52
+ to invalidate the vault session cache):
53
+
54
+ 1. ``AK_TOKEN`` (env / .env / .deploy.env) — short alias matching
55
+ the standalone Authentik scripts.
56
+ 2. ``AUTHENTIK_API_TOKEN`` (env / .env / .deploy.env or
57
+ Vaultwarden at ``granny/infra/authentik-api-token``, via the
58
+ normal :func:`granny.credentials.get_secret` chain).
59
+
60
+ For the base URL, ``AUTHENTIK_URL`` wins; default
61
+ ``https://auth.pseekoo.io``.
62
+ """
63
+ import os
64
+
65
+ from granny.credentials import get_secret
66
+
67
+ token = os.environ.get("AK_TOKEN") or get_secret("AUTHENTIK_API_TOKEN")
68
+ if not token:
69
+ raise AuthentikError(
70
+ 0,
71
+ "AUTHENTIK_API_TOKEN (or AK_TOKEN) is not set. "
72
+ "Either export it, add it to .env/.deploy.env, or push the "
73
+ "token to Vaultwarden under granny/infra/authentik-api-token "
74
+ "and install the [vault] extra.",
75
+ )
76
+ base_url = os.environ.get("AUTHENTIK_URL") or "https://auth.pseekoo.io"
77
+ return cls(base_url=base_url, token=token)
78
+
79
+ # ── HTTP plumbing ────────────────────────────────────────────────────
80
+
81
+ def request(
82
+ self,
83
+ method: str,
84
+ path: str,
85
+ *,
86
+ body: Any = None,
87
+ params: dict[str, Any] | None = None,
88
+ ) -> Any:
89
+ """Execute one API request and return the decoded JSON body.
90
+
91
+ Returns ``None`` on 204 / empty bodies.
92
+ """
93
+ if not path.startswith("/"):
94
+ path = "/" + path
95
+ url = self.base_url + path
96
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
97
+ resp = self._session.request(
98
+ method=method.upper(),
99
+ url=url,
100
+ params=clean_params or None,
101
+ json=body,
102
+ timeout=self._timeout,
103
+ )
104
+ if resp.status_code >= 400:
105
+ raise AuthentikError(resp.status_code, resp.text)
106
+ if not resp.content:
107
+ return None
108
+ try:
109
+ return resp.json()
110
+ except ValueError:
111
+ return resp.text
112
+
113
+ def paginate(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
114
+ """Walk an Authentik list endpoint, returning all pages concatenated."""
115
+ out: list[dict[str, Any]] = []
116
+ page = 1
117
+ while True:
118
+ p = dict(params or {})
119
+ p["page"] = page
120
+ data = self.request("GET", path, params=p) or {}
121
+ out.extend(data.get("results", []))
122
+ if not data.get("pagination", {}).get("next"):
123
+ break
124
+ page += 1
125
+ return out
126
+
127
+ # ── High-level lookups ───────────────────────────────────────────────
128
+
129
+ def find_flow_pk(self, slug: str) -> str:
130
+ results = self.request("GET", "/api/v3/flows/instances/", params={"slug": slug}).get(
131
+ "results", []
132
+ )
133
+ for f in results:
134
+ if f["slug"] == slug:
135
+ return f["pk"]
136
+ raise AuthentikError(404, f"No flow with slug={slug!r}")
137
+
138
+ def find_signing_key_pk(self, *, prefer_self_signed: bool = True) -> str:
139
+ """Return the pk of a key pair that can sign tokens."""
140
+ keys = self.paginate("/api/v3/crypto/certificatekeypairs/")
141
+ candidates = [k for k in keys if k.get("private_key_available")]
142
+ if not candidates:
143
+ raise AuthentikError(404, "No usable signing key pairs found")
144
+ if prefer_self_signed:
145
+ for k in candidates:
146
+ if "self-signed" in k["name"].lower():
147
+ return k["pk"]
148
+ return candidates[0]["pk"]
149
+
150
+ def find_scope_mappings(self, scope_names: tuple[str, ...] | list[str]) -> list[str]:
151
+ """Resolve scope-property-mapping pks by ``scope_name``."""
152
+ mappings = self.paginate("/api/v3/propertymappings/provider/scope/")
153
+ by_scope = {m["scope_name"]: m["pk"] for m in mappings}
154
+ try:
155
+ return [by_scope[scope] for scope in scope_names]
156
+ except KeyError as exc:
157
+ raise AuthentikError(404, f"Scope mapping for {exc.args[0]!r} not found") from exc
158
+
159
+ def find_group_by_name(self, name: str) -> dict[str, Any] | None:
160
+ for g in self.request("GET", "/api/v3/core/groups/", params={"name": name}).get(
161
+ "results", []
162
+ ):
163
+ if g["name"] == name:
164
+ return g
165
+ return None
166
+
167
+ def find_user_by_username(self, username: str) -> dict[str, Any] | None:
168
+ for u in self.request("GET", "/api/v3/core/users/", params={"username": username}).get(
169
+ "results", []
170
+ ):
171
+ if u["username"] == username:
172
+ return u
173
+ return None
174
+
175
+ def find_provider_by_name(self, name: str) -> dict[str, Any] | None:
176
+ for p in self.request("GET", "/api/v3/providers/oauth2/", params={"name": name}).get(
177
+ "results", []
178
+ ):
179
+ if p["name"] == name:
180
+ return p
181
+ return None
182
+
183
+ def find_application_by_slug(self, slug: str) -> dict[str, Any] | None:
184
+ """Look up an application by slug.
185
+
186
+ Uses a direct GET on the slug-keyed detail endpoint instead of the
187
+ list endpoint. Authentik's application list quietly hides records
188
+ whose policy bindings restrict ``view`` permissions, even for
189
+ superusers — direct GET by slug bypasses that filter.
190
+ """
191
+ try:
192
+ return self.request("GET", f"/api/v3/core/applications/{slug}/")
193
+ except AuthentikError as exc:
194
+ if exc.status == 404:
195
+ return None
196
+ raise
197
+
198
+ def find_provider_by_pk(self, pk: int | str) -> dict[str, Any] | None:
199
+ """Look up an OAuth2 provider by primary key (integer)."""
200
+ try:
201
+ return self.request("GET", f"/api/v3/providers/oauth2/{pk}/")
202
+ except AuthentikError as exc:
203
+ if exc.status == 404:
204
+ return None
205
+ raise
206
+
207
+ # ── Mutating helpers ─────────────────────────────────────────────────
208
+
209
+ def ensure_group(self, name: str) -> dict[str, Any]:
210
+ """Create a group if missing; return the (existing or new) group dict."""
211
+ existing = self.find_group_by_name(name)
212
+ if existing:
213
+ return existing
214
+ return self.request("POST", "/api/v3/core/groups/", body={"name": name})
215
+
216
+ def add_user_to_group(self, username: str, group_name: str) -> None:
217
+ user = self.find_user_by_username(username)
218
+ if not user:
219
+ raise AuthentikError(404, f"User {username!r} not found")
220
+ group = self.find_group_by_name(group_name)
221
+ if not group:
222
+ raise AuthentikError(
223
+ 404, f"Group {group_name!r} not found — create it with ensure_group first"
224
+ )
225
+ self.request(
226
+ "POST",
227
+ f"/api/v3/core/groups/{group['pk']}/add_user/",
228
+ body={"pk": user["pk"]},
229
+ )
230
+
231
+ def remove_user_from_group(self, username: str, group_name: str) -> None:
232
+ user = self.find_user_by_username(username)
233
+ if not user:
234
+ raise AuthentikError(404, f"User {username!r} not found")
235
+ group = self.find_group_by_name(group_name)
236
+ if not group:
237
+ raise AuthentikError(404, f"Group {group_name!r} not found")
238
+ self.request(
239
+ "POST",
240
+ f"/api/v3/core/groups/{group['pk']}/remove_user/",
241
+ body={"pk": user["pk"]},
242
+ )
243
+
244
+ def rotate_client_secret(self, provider_name: str) -> dict[str, Any]:
245
+ """Rotate ``client_secret`` on an existing OAuth2 provider.
246
+
247
+ Authentik regenerates the secret server-side when we PATCH it to
248
+ an empty string. The returned dict carries the new value (when
249
+ the API echoes it back — varies by version).
250
+ """
251
+ provider = self.find_provider_by_name(provider_name)
252
+ if not provider:
253
+ raise AuthentikError(404, f"Provider {provider_name!r} not found")
254
+ return self.request(
255
+ "PATCH",
256
+ f"/api/v3/providers/oauth2/{provider['pk']}/",
257
+ body={"client_secret": ""},
258
+ )
259
+
260
+ def create_user(
261
+ self,
262
+ username: str,
263
+ *,
264
+ name: str | None = None,
265
+ email: str | None = None,
266
+ path: str = "users",
267
+ user_type: str = "internal",
268
+ is_active: bool = True,
269
+ attributes: dict[str, Any] | None = None,
270
+ ) -> dict[str, Any]:
271
+ """Create an internal Authentik user (idempotent on ``username``).
272
+
273
+ If a user with the same ``username`` already exists it is returned
274
+ unmodified — call ``request("PATCH", …)`` separately if you need to
275
+ update fields. The default ``path="users"`` puts the user in the
276
+ regular Authentik directory (not under any LDAP source path), so
277
+ the user can authenticate against an internal password rather than
278
+ being bound to Kanidm.
279
+ """
280
+ existing = self.find_user_by_username(username)
281
+ if existing:
282
+ return existing
283
+ body: dict[str, Any] = {
284
+ "username": username,
285
+ "name": name or username,
286
+ "is_active": is_active,
287
+ "type": user_type,
288
+ "path": path,
289
+ "groups": [],
290
+ }
291
+ if email is not None:
292
+ body["email"] = email
293
+ if attributes:
294
+ body["attributes"] = attributes
295
+ return self.request("POST", "/api/v3/core/users/", body=body)
296
+
297
+ def set_user_password(self, username: str, password: str) -> None:
298
+ """Set a user's password directly (admin reset, no email)."""
299
+ user = self.find_user_by_username(username)
300
+ if not user:
301
+ raise AuthentikError(404, f"User {username!r} not found")
302
+ self.request(
303
+ "POST",
304
+ f"/api/v3/core/users/{user['pk']}/set_password/",
305
+ body={"password": password},
306
+ )
307
+
308
+ def delete_user(self, username: str) -> None:
309
+ """Delete a user by username. No-op if absent."""
310
+ user = self.find_user_by_username(username)
311
+ if not user:
312
+ return
313
+ self.request("DELETE", f"/api/v3/core/users/{user['pk']}/")
314
+
315
+ def generate_recovery_link(self, username: str) -> str:
316
+ """Mint a one-shot recovery URL for the given user.
317
+
318
+ Returns the absolute URL the user opens to set their own password
319
+ (and enroll MFA if the recovery flow is configured to require it).
320
+ Authentik invalidates the token after a single use; default TTL is
321
+ short, set in the recovery flow's stage configuration.
322
+ """
323
+ user = self.find_user_by_username(username)
324
+ if not user:
325
+ raise AuthentikError(404, f"User {username!r} not found")
326
+ result = self.request(
327
+ "POST",
328
+ f"/api/v3/core/users/{user['pk']}/recovery/",
329
+ )
330
+ # Authentik returns {"link": "https://..."} on this endpoint.
331
+ if isinstance(result, dict) and "link" in result:
332
+ return result["link"]
333
+ raise AuthentikError(
334
+ 500,
335
+ f"Unexpected recovery payload for {username!r}: {result!r}. "
336
+ "Make sure a recovery flow is configured on the tenant.",
337
+ )
@@ -0,0 +1,263 @@
1
+ """``granny authentik`` — manage an Authentik instance via API.
2
+
3
+ Auth resolution: ``AUTHENTIK_API_TOKEN`` (env / .env / vault) or
4
+ ``AK_TOKEN`` (env / .env). See :meth:`AuthentikClient.from_environment`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ from granny.authentik import AuthentikClient, AuthentikError
16
+
17
+ ADMIN_GROUP_NAME = "dash_admins"
18
+
19
+
20
+ @click.group()
21
+ def authentik() -> None:
22
+ """Manage Authentik via its REST API."""
23
+
24
+
25
+ def _client() -> AuthentikClient:
26
+ try:
27
+ return AuthentikClient.from_environment()
28
+ except AuthentikError as exc:
29
+ click.echo(str(exc), err=True)
30
+ sys.exit(1)
31
+
32
+
33
+ _LIST_ENDPOINTS = {
34
+ "providers": "/api/v3/providers/oauth2/",
35
+ "applications": "/api/v3/core/applications/",
36
+ "groups": "/api/v3/core/groups/",
37
+ "flows": "/api/v3/flows/instances/",
38
+ "certs": "/api/v3/crypto/certificatekeypairs/",
39
+ "scopes": "/api/v3/propertymappings/provider/scope/",
40
+ "users": "/api/v3/core/users/",
41
+ }
42
+
43
+ _LIST_COLUMNS = {
44
+ "providers": ("pk", "name", "client_id"),
45
+ "applications": ("slug", "name", "provider"),
46
+ "groups": ("pk", "name", "users_obj"),
47
+ "flows": ("slug", "name", "designation"),
48
+ "certs": ("pk", "name", "private_key_available"),
49
+ "scopes": ("pk", "name", "scope_name"),
50
+ "users": ("pk", "username", "name"),
51
+ }
52
+
53
+
54
+ @authentik.command("list")
55
+ @click.argument("kind", type=click.Choice(sorted(_LIST_ENDPOINTS)))
56
+ @click.option("--json", "as_json", is_flag=True, help="Emit full JSON instead of a table.")
57
+ def list_cmd(kind: str, as_json: bool) -> None:
58
+ """List Authentik objects."""
59
+ client = _client()
60
+ items = client.paginate(_LIST_ENDPOINTS[kind])
61
+ if as_json:
62
+ click.echo(json.dumps(items, indent=2, default=str))
63
+ return
64
+ cols = _LIST_COLUMNS[kind]
65
+ for item in items:
66
+ row_parts = []
67
+ for col in cols:
68
+ if col == "users_obj":
69
+ row_parts.append(f"{len(item.get(col, []))} users")
70
+ else:
71
+ row_parts.append(str(item.get(col, ""))[:60])
72
+ click.echo(" | ".join(row_parts))
73
+
74
+
75
+ @authentik.command("get-provider")
76
+ @click.argument("name")
77
+ def get_provider_cmd(name: str) -> None:
78
+ """Show one OAuth2 provider by name."""
79
+ client = _client()
80
+ provider = client.find_provider_by_name(name)
81
+ if not provider:
82
+ click.echo(f"Provider {name!r} not found", err=True)
83
+ sys.exit(1)
84
+ click.echo(json.dumps(provider, indent=2, default=str))
85
+
86
+
87
+ @authentik.command("rotate-secret")
88
+ @click.argument("name")
89
+ def rotate_secret_cmd(name: str) -> None:
90
+ """Rotate a provider's client_secret."""
91
+ client = _client()
92
+ try:
93
+ updated = client.rotate_client_secret(name)
94
+ except AuthentikError as exc:
95
+ click.echo(f"Authentik error: {exc}", err=True)
96
+ sys.exit(1)
97
+ new_secret = updated.get("client_secret") or "(not echoed — view via admin UI)"
98
+ click.echo(f"Rotated. New client_secret: {new_secret}")
99
+
100
+
101
+ @authentik.command("ensure-group")
102
+ @click.argument("name", default=ADMIN_GROUP_NAME)
103
+ def ensure_group_cmd(name: str) -> None:
104
+ """Create a group if it doesn't exist (defaults to dash_admins)."""
105
+ client = _client()
106
+ existing = client.find_group_by_name(name)
107
+ if existing:
108
+ click.echo(f"Group {name} already exists (pk={existing['pk']})")
109
+ return
110
+ group = client.ensure_group(name)
111
+ click.echo(f"Created group {name} (pk={group['pk']})")
112
+
113
+
114
+ @authentik.command("add-user-to-group")
115
+ @click.argument("user")
116
+ @click.argument("group", default=ADMIN_GROUP_NAME)
117
+ def add_user_to_group_cmd(user: str, group: str) -> None:
118
+ """Add a user to a group by username (group defaults to dash_admins)."""
119
+ client = _client()
120
+ try:
121
+ client.add_user_to_group(user, group)
122
+ except AuthentikError as exc:
123
+ click.echo(f"Authentik error: {exc}", err=True)
124
+ sys.exit(1)
125
+ click.echo(f"Added {user} to {group}")
126
+
127
+
128
+ @authentik.command("remove-user-from-group")
129
+ @click.argument("user")
130
+ @click.argument("group", default=ADMIN_GROUP_NAME)
131
+ def remove_user_from_group_cmd(user: str, group: str) -> None:
132
+ """Remove a user from a group by username."""
133
+ client = _client()
134
+ try:
135
+ client.remove_user_from_group(user, group)
136
+ except AuthentikError as exc:
137
+ click.echo(f"Authentik error: {exc}", err=True)
138
+ sys.exit(1)
139
+ click.echo(f"Removed {user} from {group}")
140
+
141
+
142
+ @authentik.command("create-user")
143
+ @click.argument("username")
144
+ @click.option("--name", default=None, help="Display name (defaults to username).")
145
+ @click.option("--email", default=None, help="Email address.")
146
+ @click.option(
147
+ "--group",
148
+ default=None,
149
+ help="Group to add the new user to after creation (e.g. dash_admins).",
150
+ )
151
+ @click.option(
152
+ "--password",
153
+ default=None,
154
+ help="Set this password directly. Suppresses --recovery-link.",
155
+ )
156
+ @click.option(
157
+ "--recovery-link/--no-recovery-link",
158
+ default=True,
159
+ help="Print a one-shot recovery URL the user opens to set their own password (default: yes).",
160
+ )
161
+ def create_user_cmd(
162
+ username: str,
163
+ name: str | None,
164
+ email: str | None,
165
+ group: str | None,
166
+ password: str | None,
167
+ recovery_link: bool,
168
+ ) -> None:
169
+ """Create an internal Authentik user (idempotent on username).
170
+
171
+ Defaults to printing a one-shot recovery URL so the user sets their
172
+ own password — pass ``--password`` to skip that and set one inline.
173
+ """
174
+ if password is not None:
175
+ recovery_link = False
176
+ client = _client()
177
+ try:
178
+ existed_before = client.find_user_by_username(username) is not None
179
+ user = client.create_user(username, name=name, email=email)
180
+ click.echo(
181
+ f"User {username} {'already exists' if existed_before else 'created'} (pk={user['pk']})"
182
+ )
183
+ if group:
184
+ client.add_user_to_group(username, group)
185
+ click.echo(f"Added {username} to {group}")
186
+ if password is not None:
187
+ client.set_user_password(username, password)
188
+ click.echo(f"Password set for {username}")
189
+ if recovery_link:
190
+ link = client.generate_recovery_link(username)
191
+ click.echo("")
192
+ click.echo("Recovery link (single-use, share over a secure channel):")
193
+ click.echo(f" {link}")
194
+ except AuthentikError as exc:
195
+ click.echo(f"Authentik error: {exc}", err=True)
196
+ sys.exit(1)
197
+
198
+
199
+ @authentik.command("recovery-link")
200
+ @click.argument("username")
201
+ def recovery_link_cmd(username: str) -> None:
202
+ """Mint a one-shot recovery URL the user opens to set a password."""
203
+ client = _client()
204
+ try:
205
+ link = client.generate_recovery_link(username)
206
+ except AuthentikError as exc:
207
+ click.echo(f"Authentik error: {exc}", err=True)
208
+ sys.exit(1)
209
+ click.echo(link)
210
+
211
+
212
+ @authentik.command("set-password")
213
+ @click.argument("username")
214
+ @click.option("--password", required=True, help="New password to set.")
215
+ def set_password_cmd(username: str, password: str) -> None:
216
+ """Set a user's password directly (admin reset)."""
217
+ client = _client()
218
+ try:
219
+ client.set_user_password(username, password)
220
+ except AuthentikError as exc:
221
+ click.echo(f"Authentik error: {exc}", err=True)
222
+ sys.exit(1)
223
+ click.echo(f"Password updated for {username}")
224
+
225
+
226
+ @authentik.command("delete-user")
227
+ @click.argument("username")
228
+ @click.confirmation_option(prompt="Really delete this Authentik user?")
229
+ def delete_user_cmd(username: str) -> None:
230
+ """Delete an Authentik user. No-op if the user doesn't exist."""
231
+ client = _client()
232
+ try:
233
+ client.delete_user(username)
234
+ except AuthentikError as exc:
235
+ click.echo(f"Authentik error: {exc}", err=True)
236
+ sys.exit(1)
237
+ click.echo(f"User {username} deleted (or already absent)")
238
+
239
+
240
+ @authentik.command("api")
241
+ @click.argument("method")
242
+ @click.argument("path")
243
+ @click.option(
244
+ "--data",
245
+ default=None,
246
+ help="JSON body, or '-' to read from stdin.",
247
+ )
248
+ def api_cmd(method: str, path: str, data: str | None) -> None:
249
+ """Generic API call — for anything not covered by the named commands."""
250
+ client = _client()
251
+ body: Any = None
252
+ if data is not None:
253
+ raw = sys.stdin.read() if data == "-" else data
254
+ body = json.loads(raw) if raw.strip() else None
255
+ try:
256
+ result = client.request(method, path, body=body)
257
+ except AuthentikError as exc:
258
+ click.echo(f"Authentik error: {exc}", err=True)
259
+ sys.exit(1)
260
+ if result is None:
261
+ click.echo("(no content)", err=True)
262
+ else:
263
+ click.echo(json.dumps(result, indent=2, default=str))
@@ -95,6 +95,11 @@ def _register_commands() -> None:
95
95
  cli.add_command(email)
96
96
  except ImportError:
97
97
  pass
98
+ try:
99
+ from granny.cli.authentik import authentik
100
+ cli.add_command(authentik)
101
+ except ImportError:
102
+ pass
98
103
 
99
104
 
100
105
  _register_commands()
@@ -213,11 +213,11 @@ def deploy(
213
213
  """Zip ``--source-dir``, upload, sync env vars, and deploy.
214
214
 
215
215
  Example:
216
- granny serverless deploy bessa-wissa-api \\
216
+ granny serverless deploy my-api \\
217
217
  --source-dir backend/dist-bundle \\
218
- --namespace bessa-wissa \\
218
+ --namespace my-app \\
219
219
  --env NODE_ENV=production \\
220
- --secret STOZ3N_API_TOKEN=$STOZ3N_API_TOKEN
220
+ --secret API_TOKEN=$API_TOKEN
221
221
  """
222
222
  c = _client(region)
223
223
 
@@ -168,6 +168,10 @@ SECRET_MAP: dict[str, str] = {
168
168
  # GitLab PyPI / Container Registry (for fetching private packages and pushing images)
169
169
  "GITLAB_PYPI_USER": "gitlab-pypi-user",
170
170
  "GITLAB_PYPI_PASSWORD": "gitlab-pypi-password",
171
+ # Authentik admin API token for `granny authentik …` commands. Long-lived
172
+ # superuser-issued token (Authentik UI → Directory → Tokens). Falls back
173
+ # to AK_TOKEN env var in granny.authentik.AuthentikClient.from_environment.
174
+ "AUTHENTIK_API_TOKEN": "authentik-api-token",
171
175
  }
172
176
 
173
177
  # Module-level cache so vault is only contacted once per process
@@ -1,127 +1,127 @@
1
- [project]
2
- name = "granny-devops"
3
- version = "0.8.0"
4
- description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
- readme = "README.md"
6
- license = { file = "LICENSE" }
7
- authors = [{ name = "Martin Wieser", email = "martin.wieser@pseekoo.com" }]
8
- requires-python = ">=3.13"
9
- dependencies = [
10
- "boto3>=1.38",
11
- "click>=8.1",
12
- "python-dotenv>=1.1.0",
13
- "requests>=2.32",
14
- ]
15
-
16
- [project.optional-dependencies]
17
- # Development and release tooling
18
- dev = [
19
- "hatch>=1.14",
20
- "prek>=0.2",
21
- "pytest>=8.3",
22
- "ruff>=0.11",
23
- "twine>=6.1",
24
- ]
25
- # Vaultwarden-backed credential resolution via Locke. The published wheel
26
- # leaves this extra empty because PyPI rejects direct Git dependencies; for
27
- # local editable installs Locke is wired in via `[tool.uv]` dev-dependencies
28
- # below, so `uv sync` activates vault support automatically.
29
- vault = []
30
- # CDN and DNS providers
31
- cdn = [
32
- "cloudflare==4.3.1",
33
- "hetzner-dns-api>=1.0.0",
34
- ]
35
- # Google Cloud (cross-cloud analyze commands: GPUs, credits, costs)
36
- gcp = [
37
- "google-cloud-billing>=1.16",
38
- "google-cloud-compute>=1.21",
39
- "google-cloud-resource-manager>=1.12",
40
- ]
41
- # Microsoft Azure (cross-cloud analyze commands: GPUs, credits, costs)
42
- azure = [
43
- "azure-identity>=1.23",
44
- "azure-mgmt-billing>=6.1",
45
- "azure-mgmt-compute>=33",
46
- "azure-mgmt-consumption>=10.0",
47
- "azure-mgmt-costmanagement>=4.0",
48
- "azure-mgmt-reservations>=2.3",
49
- "azure-mgmt-subscription>=3.1",
50
- ]
51
- # Data processing and export
52
- data = [
53
- "beautifulsoup4~=4.13.4",
54
- "gspread>=6.2.1",
55
- "openpyxl==3.1.5",
56
- "pandas>=2.3.0",
57
- "pyairtable~=3.1.1",
58
- "pymongo>=4.13",
59
- ]
60
- # AI/ML integrations
61
- ml = [
62
- "anthropic>=0.54.0",
63
- "google-generativeai>=0.8.5",
64
- "langchain>=0.3.26",
65
- "langchain-openai>=0.3.24",
66
- "litellm>=1.73.0",
67
- "ollama>=0.5.1",
68
- "openai>=1.90.0",
69
- "tiktoken>=0.9.0",
70
- ]
71
- # Browser automation
72
- browser = [
73
- "playwright>=1.52.0",
74
- "selenium>=4.33.0",
75
- "webdriver-manager>=4.0.2",
76
- ]
77
- # Full install (everything)
78
- all = [
79
- "granny-devops[cdn,data,ml,browser,gcp,azure]",
80
- "azure-identity>=1.23.0",
81
- "boto3-stubs>=1.40",
82
- "certifi>=2025.6.15",
83
- "earthengine-api>=1.5.20",
84
- "elasticsearch>=8.0.0,<9.0.0",
85
- "elasticsearch-dsl>=8.0.0,<9.0.0",
86
- "flask>=3.1.1",
87
- "flask-cors>=6.0.1",
88
- "google-auth>=2.40.3",
89
- "ipython~=9.3.0",
90
- "json5>=0.12.0",
91
- "jsonschema>=4.0.0",
92
- "langdetect>=1.0.9",
93
- "matplotlib>=3.10.3",
94
- "msoffcrypto-tool>=5.4.2",
95
- "numpy>=2.3.1",
96
- "pdf2image>=1.17.0",
97
- "pillow>=11.2.1",
98
- "pydantic>=2.11.7",
99
- "pymilvus>=2.5.11",
100
- "pypdf>=5.6.1",
101
- "pytesseract>=0.3.13",
102
- "pyzbar>=0.1.9",
103
- "sentinelhub>=3.11.1",
104
- "sentry-sdk>=2.30.0",
105
- "zxing>=1.0.3",
106
- ]
107
-
108
- [project.scripts]
109
- granny = "granny.cli.main:cli"
110
-
111
- [build-system]
112
- requires = ["hatchling"]
113
- build-backend = "hatchling.build"
114
-
115
- [tool.hatch.build.targets.wheel]
116
- packages = ["granny"]
117
-
118
- [tool.hatch.build.targets.sdist]
119
- include = ["granny"]
120
-
121
- # uv-only configuration. PyPI publishes ignore this section -- it activates
122
- # vault support (Locke) for local editable installs via `uv sync`, without
123
- # polluting the published wheel's metadata with a Git URL that PyPI rejects.
124
- [tool.uv]
125
- dev-dependencies = [
126
- "locke @ git+https://gitlab.com/martin-wieser/locke.git#subdirectory=python",
127
- ]
1
+ [project]
2
+ name = "granny-devops"
3
+ version = "0.9.1"
4
+ description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ authors = [{ name = "Martin Wieser", email = "martin.wieser@pseekoo.com" }]
8
+ requires-python = ">=3.13"
9
+ dependencies = [
10
+ "boto3>=1.38",
11
+ "click>=8.1",
12
+ "python-dotenv>=1.1.0",
13
+ "requests>=2.32",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ # Development and release tooling
18
+ dev = [
19
+ "hatch>=1.14",
20
+ "prek>=0.2",
21
+ "pytest>=8.3",
22
+ "ruff>=0.11",
23
+ "twine>=6.1",
24
+ ]
25
+ # Vaultwarden-backed credential resolution via Locke. The published wheel
26
+ # leaves this extra empty because PyPI rejects direct Git dependencies; for
27
+ # local editable installs Locke is wired in via `[tool.uv]` dev-dependencies
28
+ # below, so `uv sync` activates vault support automatically.
29
+ vault = []
30
+ # CDN and DNS providers
31
+ cdn = [
32
+ "cloudflare==4.3.1",
33
+ "hetzner-dns-api>=1.0.0",
34
+ ]
35
+ # Google Cloud (cross-cloud analyze commands: GPUs, credits, costs)
36
+ gcp = [
37
+ "google-cloud-billing>=1.16",
38
+ "google-cloud-compute>=1.21",
39
+ "google-cloud-resource-manager>=1.12",
40
+ ]
41
+ # Microsoft Azure (cross-cloud analyze commands: GPUs, credits, costs)
42
+ azure = [
43
+ "azure-identity>=1.23",
44
+ "azure-mgmt-billing>=6.1",
45
+ "azure-mgmt-compute>=33",
46
+ "azure-mgmt-consumption>=10.0",
47
+ "azure-mgmt-costmanagement>=4.0",
48
+ "azure-mgmt-reservations>=2.3",
49
+ "azure-mgmt-subscription>=3.1",
50
+ ]
51
+ # Data processing and export
52
+ data = [
53
+ "beautifulsoup4~=4.13.4",
54
+ "gspread>=6.2.1",
55
+ "openpyxl==3.1.5",
56
+ "pandas>=2.3.0",
57
+ "pyairtable~=3.1.1",
58
+ "pymongo>=4.13",
59
+ ]
60
+ # AI/ML integrations
61
+ ml = [
62
+ "anthropic>=0.54.0",
63
+ "google-generativeai>=0.8.5",
64
+ "langchain>=0.3.26",
65
+ "langchain-openai>=0.3.24",
66
+ "litellm>=1.73.0",
67
+ "ollama>=0.5.1",
68
+ "openai>=1.90.0",
69
+ "tiktoken>=0.9.0",
70
+ ]
71
+ # Browser automation
72
+ browser = [
73
+ "playwright>=1.52.0",
74
+ "selenium>=4.33.0",
75
+ "webdriver-manager>=4.0.2",
76
+ ]
77
+ # Full install (everything)
78
+ all = [
79
+ "granny-devops[cdn,data,ml,browser,gcp,azure]",
80
+ "azure-identity>=1.23.0",
81
+ "boto3-stubs>=1.40",
82
+ "certifi>=2025.6.15",
83
+ "earthengine-api>=1.5.20",
84
+ "elasticsearch>=8.0.0,<9.0.0",
85
+ "elasticsearch-dsl>=8.0.0,<9.0.0",
86
+ "flask>=3.1.1",
87
+ "flask-cors>=6.0.1",
88
+ "google-auth>=2.40.3",
89
+ "ipython~=9.3.0",
90
+ "json5>=0.12.0",
91
+ "jsonschema>=4.0.0",
92
+ "langdetect>=1.0.9",
93
+ "matplotlib>=3.10.3",
94
+ "msoffcrypto-tool>=5.4.2",
95
+ "numpy>=2.3.1",
96
+ "pdf2image>=1.17.0",
97
+ "pillow>=11.2.1",
98
+ "pydantic>=2.11.7",
99
+ "pymilvus>=2.5.11",
100
+ "pypdf>=5.6.1",
101
+ "pytesseract>=0.3.13",
102
+ "pyzbar>=0.1.9",
103
+ "sentinelhub>=3.11.1",
104
+ "sentry-sdk>=2.30.0",
105
+ "zxing>=1.0.3",
106
+ ]
107
+
108
+ [project.scripts]
109
+ granny = "granny.cli.main:cli"
110
+
111
+ [build-system]
112
+ requires = ["hatchling"]
113
+ build-backend = "hatchling.build"
114
+
115
+ [tool.hatch.build.targets.wheel]
116
+ packages = ["granny"]
117
+
118
+ [tool.hatch.build.targets.sdist]
119
+ include = ["granny"]
120
+
121
+ # uv-only configuration. PyPI publishes ignore this section -- it activates
122
+ # vault support (Locke) for local editable installs via `uv sync`, without
123
+ # polluting the published wheel's metadata with a Git URL that PyPI rejects.
124
+ [tool.uv]
125
+ dev-dependencies = [
126
+ "locke @ git+https://gitlab.com/martin-wieser/locke.git#subdirectory=python",
127
+ ]
File without changes
File without changes